Pixel art can be drawn in its own tab with layers and frames (#130)
game-maker / gate (push) Successful in 2m24s
game-maker / suite (push) Failing after 7m40s

Textures are uploaded files named by their content, so they cannot
be edited in place. Pixel art is therefore its own asset type, like
vector art. Its pixels live in the project and the tab rasters them
into a texture that sprites, tiles and animations can use.

- New asset types Pixel art and Palette. The first pixel art of a
  project gets a palette with the 16 PICO-8 colours in the same undo
  step. Every pixel is a palette index, 0 is transparent.
- Each layer on each frame is its own file data/pixels/<sha256>.px.
  The project only holds the names, so undo swaps names. The browser
  computes the hash and uploads new cels before the project is saved.
- New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201
  and 422 for broken data. data/pixels/ is git-ignored.
- Tools: pencil, eraser, fill (contiguous or all pixels of a colour),
  eyedropper, line, rectangle and ellipse (outline or filled), brush
  size 1 to 8.
- Layers can be added, deleted, renamed, reordered, hidden, faded and
  merged down. Frames can be added, duplicated, deleted and reordered,
  with onion skin and a small playing preview.
- Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and
  Resize... with a 3x3 anchor.
- All frames sit side by side in one image. Each frame is a region
  like a cut texture frame, so texture rows offer "Asset / Frame 2"
  and the context menu has Create animation from frames.

Decisions:
- Merging down drops the opacity of the upper layer. Indexed colours
  know no blends.
- Only the last palette colour can be removed. Otherwise the indices
  of every image using the palette would shift.
- Undo stores the whole layer per stroke instead of pixel diffs.
- Alt+click picks a colour. It is a mouse gesture, not a shortcut.

Follow-up issues: #197 import from texture, #198 selection and
flipping, #199 PNG export, #200 more tools.

Tests:
- ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in
  tests/core/pixel-art.test.ts, 4 from the icon tables.
- e2e module 48 fresh: ALL PASSED, 29 checks, console empty.
- Counter-probes bite: five on the unit tests, upload order (2 tests
  fail) and cels not uploaded in the browser (1 check fails).
- Check total 1259 in README is computed (1232 measured in CI + 27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 17:33:44 +02:00
1 parent 1c8a20fcac
commit 66d6aafbb4
43 files changed
+4018 -24

No files matched your search

+47
View File
@@ -330,6 +330,51 @@ export interface VectorArtAsset extends AssetBase {
shapes: VectorShape[];
}
/**
* One drawing layer of pixel art (#130). The list order is the drawing order, the last layer on top,
* as with vector shapes. `opacity` runs from 0 to 1; absent `hidden` is shown.
*/
export interface PixelLayer {
id: string;
name: string;
hidden?: boolean;
opacity: number;
}
/**
* One picture of the sequence. `cels` names the pixels of each layer on this frame: a file in
* `data/pixels/` (see `model/pixelCel.ts`), content-addressed like a texture. A layer missing here, or
* named with `''`, is empty on this frame. The id shares the generator with asset ids, so a sprite's
* Texture row can name a frame the way it names the frame of a sliced texture.
*/
export interface PixelFrame {
id: string;
cels: Record<string, string>;
}
/**
* A picture painted pixel by pixel (#130). Every pixel is an index into the palette the `palette` row
* names, 0 being transparent. The canvas size is intrinsic here, not a property as with vector art:
* resizing rewrites every cel, so it is a command with an anchor rather than a free row. All frames
* are drawn side by side into one generated picture (#185), and a frame reads as a cut of it.
*/
export interface PixelArtAsset extends AssetBase {
type: 'pixelart';
width: number;
height: number;
layers: PixelLayer[];
frames: PixelFrame[];
}
/**
* The colours pixel art is painted with (#130). Index 0 is transparent and not stored, so `colors[0]`
* is index 1. Several pixel art assets may share one palette; changing a colour here recolours all of them.
*/
export interface PaletteAsset extends AssetBase {
type: 'palette';
colors: number[];
}
/**
* The graph is the motion of one particle: every output of its `Output` node is a function of the
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
@@ -352,6 +397,8 @@ export type Asset =
| ContainerAsset
| TextureAsset
| VectorArtAsset
| PixelArtAsset
| PaletteAsset
| AnimationAsset
| TileAsset
| TilemapAsset
+8
View File
@@ -10,6 +10,8 @@ export const ASSET_TYPES = [
'container',
'texture',
'vectorart',
'pixelart',
'palette',
'animation',
'tile',
'tilemap',
@@ -34,6 +36,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
container: 'Container',
texture: 'Texture',
vectorart: 'Vector art',
pixelart: 'Pixel art',
palette: 'Palette',
animation: 'Animation',
tile: 'Tile',
tilemap: 'Tilemap',
@@ -57,6 +61,8 @@ export type PlaceableAssetType = Exclude<
| 'scene'
| 'texture'
| 'vectorart'
| 'pixelart'
| 'palette'
| 'animation'
| 'tile'
| 'statemachine'
@@ -70,6 +76,8 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'scene',
'texture',
'vectorart',
'pixelart',
'palette',
'animation',
'tile',
'statemachine',
+133
View File
@@ -0,0 +1,133 @@
/**
* The pixels of one layer on one frame of pixel art (#130), as they are stored on the server.
*
* A cel is a file of its own, `data/pixels/<sha256>.px`, content-addressed like a texture: the project
* only holds the name, so a big canvas with many layers and frames stays far below the 1 MB nginx lets
* through per request (answer 1). The name is worked out here, synchronously, from the bytes – so a
* stroke can put its cel into the model at once and undo is a swap of two names. The server hashes
* the bytes again and refuses a name that does not match.
*
* The bytes: `GMPX`, a version byte (1), a reserved byte, width and height as little-endian u16, then
* one palette index per pixel, row by row. 0 is transparent.
*/
const MAGIC = [0x47, 0x4d, 0x50, 0x58];
const VERSION = 1;
export const CEL_HEADER_BYTES = 10;
/** The largest side a canvas may have (answer 3). */
export const PIXEL_CANVAS_MAX = 512;
const FILE_PATTERN = /^[a-f0-9]{64}\.px$/;
export function isCelFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/** The pixels of a cel as indices, `width * height` of them. */
export interface CelPixels {
width: number;
height: number;
data: Uint8Array;
}
export function emptyCel(width: number, height: number): CelPixels {
return { width, height, data: new Uint8Array(width * height) };
}
export function isEmptyCel(cel: CelPixels): boolean {
return cel.data.every((index) => index === 0);
}
export function encodeCel(cel: CelPixels): Uint8Array {
const bytes = new Uint8Array(CEL_HEADER_BYTES + cel.width * cel.height);
bytes.set(MAGIC, 0);
bytes[4] = VERSION;
bytes[6] = cel.width & 0xff;
bytes[7] = cel.width >> 8;
bytes[8] = cel.height & 0xff;
bytes[9] = cel.height >> 8;
bytes.set(cel.data.subarray(0, cel.width * cel.height), CEL_HEADER_BYTES);
return bytes;
}
/** Null for anything that is not a cel of a size a canvas may have. */
export function decodeCel(bytes: Uint8Array): CelPixels | null {
if (bytes.length < CEL_HEADER_BYTES) return null;
if (MAGIC.some((byte, i) => bytes[i] !== byte) || bytes[4] !== VERSION) return null;
const width = bytes[6]! | (bytes[7]! << 8);
const height = bytes[8]! | (bytes[9]! << 8);
if (width < 1 || height < 1 || width > PIXEL_CANVAS_MAX || height > PIXEL_CANVAS_MAX) return null;
if (bytes.length !== CEL_HEADER_BYTES + width * height) return null;
return { width, height, data: bytes.slice(CEL_HEADER_BYTES) };
}
export function celFileName(bytes: Uint8Array): string {
return `${sha256Hex(bytes)}.px`;
}
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
0xc67178f2,
]);
/**
* SHA-256 as hex. `crypto.subtle` would do it, but only asynchronously – and a command has to know
* the name of the cel it puts into the model when it runs.
*/
export function sha256Hex(message: Uint8Array): string {
const length = message.length;
const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
padded.set(message);
padded[length] = 0x80;
const bits = length * 8;
const view = new DataView(padded.buffer);
view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000));
view.setUint32(padded.length - 4, bits >>> 0);
const h = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const w = new Uint32Array(64);
for (let offset = 0; offset < padded.length; offset += 64) {
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
for (let i = 16; i < 64; i++) {
const x = w[i - 15]!;
const y = w[i - 2]!;
const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10);
w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
}
let a = h[0]!;
let b = h[1]!;
let c = h[2]!;
let d = h[3]!;
let e = h[4]!;
let f = h[5]!;
let g = h[6]!;
let hh = h[7]!;
for (let i = 0; i < 64; i++) {
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i]! + w[i]!) >>> 0;
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
hh = g;
g = f;
f = e;
e = (d + t1) >>> 0;
d = c;
c = b;
b = a;
a = (t1 + t2) >>> 0;
}
const sums = [a, b, c, d, e, f, g, hh];
for (let i = 0; i < 8; i++) h[i] = (h[i]! + sums[i]!) >>> 0;
}
return Array.from(h, (word) => word.toString(16).padStart(8, '0')).join('');
}
function rotr(x: number, n: number): number {
return (x >>> n) | (x << (32 - n));
}
+172
View File
@@ -0,0 +1,172 @@
/**
* What the tools of the pixel art tab do to a cel (#130), as plain functions on the indices – so every
* rule can be measured without a browser. They write into the cel they are given; the tab hands them a
* copy and turns the result into a new cel file once the stroke ends.
*/
import { type CelPixels, emptyCel } from './pixelCel';
export interface PixelPoint {
x: number;
y: number;
}
/** Brush sizes the tools offer (answer 6). */
export const BRUSH_MIN = 1;
export const BRUSH_MAX = 8;
export function cloneCel(cel: CelPixels): CelPixels {
return { width: cel.width, height: cel.height, data: cel.data.slice() };
}
export function pixelAt(cel: CelPixels, x: number, y: number): number {
return x >= 0 && y >= 0 && x < cel.width && y < cel.height ? cel.data[y * cel.width + x]! : 0;
}
function setPixel(cel: CelPixels, x: number, y: number, index: number): void {
if (x >= 0 && y >= 0 && x < cel.width && y < cel.height) cel.data[y * cel.width + x] = index;
}
/**
* A square brush of `size` pixels on the point. An even size has no centre pixel, so it reaches one
* further right and down than left and up – the way Aseprite places it.
*/
export function stamp(cel: CelPixels, at: PixelPoint, size: number, index: number): void {
const n = Math.max(BRUSH_MIN, Math.min(BRUSH_MAX, Math.round(size)));
const x0 = at.x - Math.floor((n - 1) / 2);
const y0 = at.y - Math.floor((n - 1) / 2);
for (let y = y0; y < y0 + n; y++) for (let x = x0; x < x0 + n; x++) setPixel(cel, x, y, index);
}
/** The pixels of a straight line from one point to the other, both included (Bresenham). */
export function linePoints(from: PixelPoint, to: PixelPoint): PixelPoint[] {
const points: PixelPoint[] = [];
let x = from.x;
let y = from.y;
const dx = Math.abs(to.x - x);
const dy = -Math.abs(to.y - y);
const sx = x < to.x ? 1 : -1;
const sy = y < to.y ? 1 : -1;
let error = dx + dy;
for (;;) {
points.push({ x, y });
if (x === to.x && y === to.y) return points;
const twice = 2 * error;
if (twice >= dy) {
error += dy;
x += sx;
}
if (twice <= dx) {
error += dx;
y += sy;
}
}
}
function box(a: PixelPoint, b: PixelPoint): { x0: number; y0: number; x1: number; y1: number } {
return { x0: Math.min(a.x, b.x), y0: Math.min(a.y, b.y), x1: Math.max(a.x, b.x), y1: Math.max(a.y, b.y) };
}
/** The pixels of a rectangle between two corners, both included: its border, or all of it. */
export function rectPoints(a: PixelPoint, b: PixelPoint, filled: boolean): PixelPoint[] {
const { x0, y0, x1, y1 } = box(a, b);
const points: PixelPoint[] = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++)
if (filled || x === x0 || x === x1 || y === y0 || y === y1) points.push({ x, y });
return points;
}
/**
* The pixels of the ellipse that fits the box between two corners. Filled, it is every pixel whose
* centre lies inside; the outline is the filled pixels with a neighbour outside, which gives a line
* one pixel wide without gaps – what a pixel artist expects rather than a thin sampled curve.
*/
export function ellipsePoints(a: PixelPoint, b: PixelPoint, filled: boolean): PixelPoint[] {
const { x0, y0, x1, y1 } = box(a, b);
const cx = (x0 + x1 + 1) / 2;
const cy = (y0 + y1 + 1) / 2;
const rx = (x1 - x0 + 1) / 2;
const ry = (y1 - y0 + 1) / 2;
const inside = (x: number, y: number): boolean => {
if (x < x0 || x > x1 || y < y0 || y > y1) return false;
const nx = (x + 0.5 - cx) / rx;
const ny = (y + 0.5 - cy) / ry;
return nx * nx + ny * ny <= 1;
};
const points: PixelPoint[] = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) {
if (!inside(x, y)) continue;
const edge = !inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1);
if (filled || edge) points.push({ x, y });
}
return points;
}
/** Every point stamped with the brush – how a line, a rectangle or an ellipse outline gets its width. */
export function stampAll(cel: CelPixels, points: readonly PixelPoint[], size: number, index: number): void {
for (const point of points) stamp(cel, point, size, index);
}
/**
* Fills with `index`: the area of the same index the pixel lies in, joined along edges (not corners),
* or – `contiguous` off – every pixel of that index on the cel. Outside the cel, nothing happens.
*/
export function floodFill(cel: CelPixels, at: PixelPoint, index: number, contiguous: boolean): void {
const { width, height, data } = cel;
if (at.x < 0 || at.y < 0 || at.x >= width || at.y >= height) return;
const target = data[at.y * width + at.x]!;
if (target === index) return;
if (!contiguous) {
for (let i = 0; i < data.length; i++) if (data[i] === target) data[i] = index;
return;
}
const stack = [at.y * width + at.x];
data[at.y * width + at.x] = index;
while (stack.length > 0) {
const i = stack.pop() as number;
const x = i % width;
const neighbours = [x > 0 ? i - 1 : -1, x < width - 1 ? i + 1 : -1, i - width, i + width];
for (const n of neighbours)
if (n >= 0 && n < data.length && data[n] === target) {
data[n] = index;
stack.push(n);
}
}
}
/** Where the old canvas sits in the new one: each axis at its start (0), middle (0.5) or end (1). */
export interface ResizeAnchor {
x: 0 | 0.5 | 1;
y: 0 | 0.5 | 1;
}
/** How far the old canvas is shifted in the new one; negative when a side is cut off. */
export function anchorOffset(from: number, to: number, anchor: 0 | 0.5 | 1): number {
return Math.floor((to - from) * anchor);
}
/** The cel on a canvas of another size: pixels keep their place relative to the anchor, what sticks out is cut. */
export function resizeCel(cel: CelPixels, width: number, height: number, anchor: ResizeAnchor): CelPixels {
const out = emptyCel(width, height);
const dx = anchorOffset(cel.width, width, anchor.x);
const dy = anchorOffset(cel.height, height, anchor.y);
for (let y = 0; y < cel.height; y++)
for (let x = 0; x < cel.width; x++) setPixel(out, x + dx, y + dy, cel.data[y * cel.width + x]!);
return out;
}
/**
* The layer below with the one above laid over it: every pixel the upper layer paints covers the
* lower one. Indexed colours have no in-between colour, so the upper layer's opacity cannot be kept –
* the merged layer takes the lower one's.
*/
export function mergeCels(lower: CelPixels, upper: CelPixels): CelPixels {
const out = cloneCel(lower);
for (let y = 0; y < Math.min(lower.height, upper.height); y++)
for (let x = 0; x < Math.min(lower.width, upper.width); x++) {
const index = upper.data[y * upper.width + x]!;
if (index !== 0) out.data[y * out.width + x] = index;
}
return out;
}
+204
View File
@@ -0,0 +1,204 @@
/**
* The edits of a pixel art asset (#130) as plain functions from one state to the next. A state is
* names and numbers only – the pixels are in cel files the state names – so it is cheap to copy, and
* one command can take any of these edits back by putting the state before it in place again
* (see `EditPixelArtCommand`). Each function returns a new state and leaves the one it is given alone;
* one that cannot do what is asked throws a `PixelEditError` with the reason in words.
*/
import type { PixelArtAsset, PixelFrame, PixelLayer } from './Asset';
import { PIXEL_CANVAS_MAX } from './pixelCel';
export interface PixelArtState {
width: number;
height: number;
layers: PixelLayer[];
frames: PixelFrame[];
}
export class PixelEditError extends Error {}
export function pixelArtState(asset: PixelArtAsset): PixelArtState {
return cloneState(asset);
}
export function cloneState(state: PixelArtState): PixelArtState {
return {
width: state.width,
height: state.height,
layers: state.layers.map((layer) => ({ ...layer })),
frames: state.frames.map((frame) => ({ id: frame.id, cels: { ...frame.cels } })),
};
}
export function applyState(asset: PixelArtAsset, state: PixelArtState): void {
const copy = cloneState(state);
asset.width = copy.width;
asset.height = copy.height;
asset.layers = copy.layers;
asset.frames = copy.frames;
}
function layerIndex(state: PixelArtState, layerId: string): number {
const index = state.layers.findIndex((layer) => layer.id === layerId);
if (index < 0) throw new PixelEditError(`Layer ${layerId} not found`);
return index;
}
function frameIndex(state: PixelArtState, frameId: string): number {
const index = state.frames.findIndex((frame) => frame.id === frameId);
if (index < 0) throw new PixelEditError(`Frame ${frameId} not found`);
return index;
}
/** "Layer n" with the first number no layer uses yet. */
export function nextLayerName(state: PixelArtState): string {
const taken = new Set(state.layers.map((layer) => layer.name));
for (let n = state.layers.length + 1; ; n++) if (!taken.has(`Layer ${n}`)) return `Layer ${n}`;
}
/** Cels of several layers and frames at once – what one stroke, fill or shape leaves behind. */
export interface CelChange {
frameId: string;
layerId: string;
/** The new cel file, `''` for an empty cel. */
file: string;
}
export function withCels(state: PixelArtState, changes: readonly CelChange[]): PixelArtState {
const next = cloneState(state);
for (const change of changes) {
layerIndex(next, change.layerId);
const frame = next.frames[frameIndex(next, change.frameId)]!;
if (change.file) frame.cels[change.layerId] = change.file;
else delete frame.cels[change.layerId];
}
return next;
}
/** A new empty layer right above `aboveId`, or on top when that is empty. */
export function withLayerAdded(state: PixelArtState, id: string, aboveId = ''): PixelArtState {
const next = cloneState(state);
const at = aboveId ? layerIndex(next, aboveId) + 1 : next.layers.length;
next.layers.splice(at, 0, { id, name: nextLayerName(state), opacity: 1 });
return next;
}
/** Takes a layer out, with its cels on every frame. The last layer stays: there would be nothing to paint on. */
export function withLayerRemoved(state: PixelArtState, layerId: string): PixelArtState {
const next = cloneState(state);
const index = layerIndex(next, layerId);
if (next.layers.length <= 1) throw new PixelEditError('Pixel art keeps at least one layer');
next.layers.splice(index, 1);
for (const frame of next.frames) delete frame.cels[layerId];
return next;
}
export function withLayerChanged(
state: PixelArtState,
layerId: string,
change: Partial<Pick<PixelLayer, 'name' | 'hidden' | 'opacity'>>,
): PixelArtState {
const next = cloneState(state);
const layer = next.layers[layerIndex(next, layerId)]!;
if (change.name !== undefined) {
const name = change.name.trim();
if (!name) throw new PixelEditError('A layer needs a name');
layer.name = name;
}
if (change.hidden !== undefined) {
if (change.hidden) layer.hidden = true;
else delete layer.hidden;
}
if (change.opacity !== undefined) {
if (!Number.isFinite(change.opacity)) throw new PixelEditError('Opacity is a number from 0 to 1');
layer.opacity = Math.max(0, Math.min(1, change.opacity));
}
return next;
}
/** Moves a layer to another place in the drawing order; the index counts from the bottom. */
export function withLayerMoved(state: PixelArtState, layerId: string, to: number): PixelArtState {
const next = cloneState(state);
const from = layerIndex(next, layerId);
const target = Math.max(0, Math.min(next.layers.length - 1, Math.round(to)));
if (target === from) throw new PixelEditError('The layer is already there');
const [layer] = next.layers.splice(from, 1);
next.layers.splice(target, 0, layer!);
return next;
}
/**
* Lays a layer onto the one below it and takes it out. `merged` names the cel the lower layer has on
* each frame afterwards – working the pixels out is the editor's part, it holds them.
*/
export function withLayerMergedDown(
state: PixelArtState,
layerId: string,
merged: Readonly<Record<string, string>>,
): PixelArtState {
const index = layerIndex(state, layerId);
if (index === 0) throw new PixelEditError('There is no layer below to merge into');
const lower = state.layers[index - 1]!;
const changes = state.frames.map((frame) => ({
frameId: frame.id,
layerId: lower.id,
file: merged[frame.id] ?? '',
}));
return withLayerRemoved(withCels(state, changes), layerId);
}
/** A new frame right after `afterId` (at the end when empty): empty, or a copy of the frame `copyOf`. */
export function withFrameAdded(state: PixelArtState, id: string, afterId = '', copyOf = ''): PixelArtState {
const next = cloneState(state);
const at = afterId ? frameIndex(next, afterId) + 1 : next.frames.length;
const cels = copyOf ? { ...next.frames[frameIndex(next, copyOf)]!.cels } : {};
next.frames.splice(at, 0, { id, cels });
return next;
}
export function withFrameRemoved(state: PixelArtState, frameId: string): PixelArtState {
const next = cloneState(state);
const index = frameIndex(next, frameId);
if (next.frames.length <= 1) throw new PixelEditError('Pixel art keeps at least one frame');
next.frames.splice(index, 1);
return next;
}
export function withFrameMoved(state: PixelArtState, frameId: string, to: number): PixelArtState {
const next = cloneState(state);
const from = frameIndex(next, frameId);
const target = Math.max(0, Math.min(next.frames.length - 1, Math.round(to)));
if (target === from) throw new PixelEditError('The frame is already there');
const [frame] = next.frames.splice(from, 1);
next.frames.splice(target, 0, frame!);
return next;
}
/**
* The canvas at another size. `cels` maps every old cel file to the one it becomes – worked out by
* the editor, which holds the pixels; a cel it maps to `''` is empty afterwards.
*/
export function withCanvasSize(
state: PixelArtState,
width: number,
height: number,
cels: Readonly<Record<string, string>>,
): PixelArtState {
if (!isCanvasSide(width) || !isCanvasSide(height))
throw new PixelEditError(`A side is 1 to ${PIXEL_CANVAS_MAX} pixels`);
const next = cloneState(state);
next.width = width;
next.height = height;
for (const frame of next.frames)
for (const [layerId, file] of Object.entries(frame.cels)) {
const resized = cels[file];
if (resized === undefined) throw new PixelEditError('A cel of the old canvas has no counterpart');
if (resized) frame.cels[layerId] = resized;
else delete frame.cels[layerId];
}
return next;
}
export function isCanvasSide(side: number): boolean {
return Number.isInteger(side) && side >= 1 && side <= PIXEL_CANVAS_MAX;
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Pixel art (#130): layers and frames of indexed pixels, drawn into one generated picture (#185).
* This module is the model side – what a canvas is, which colours it is painted with, how the layers
* of a frame come together, and what makes the picture a new one. The pixels themselves live in cel
* files (see `pixelCel.ts`); whoever composes a frame is handed a way to read them.
*/
import type { Asset, PaletteAsset, PixelArtAsset, PixelFrame, PixelLayer } from './Asset';
import type { GeneratedPicture } from './generated';
import { type CelPixels, PIXEL_CANVAS_MAX } from './pixelCel';
import { findAsset, findAssetOf, type Project } from './Project';
import { textOf } from './Properties';
export const PALETTE_KEY = 'palette';
export const PIXEL_CANVAS_DEFAULT = 32;
/** Index 0 is transparent, so a palette holds at most 255 colours. */
export const PALETTE_MAX = 255;
/**
* The colours a new palette starts with, and what pixel art without a palette is drawn in: the
* sixteen of PICO-8 – dark and light, warm and cold, a set most pixel artists know.
*/
export const DEFAULT_PALETTE: readonly number[] = [
0x000000, 0x1d2b53, 0x7e2553, 0x008751, 0xab5236, 0x5f574f, 0xc2c3c7, 0xfff1e8, 0xff004d, 0xffa300,
0xffec27, 0x00e436, 0x29adff, 0x83769c, 0xff77a8, 0xffccaa,
];
/** A side in pixels, forced into what a canvas may be. */
export function canvasSide(value: unknown): number {
const side = Math.floor(Number(value));
return Number.isFinite(side) && side >= 1 ? Math.min(side, PIXEL_CANVAS_MAX) : PIXEL_CANVAS_DEFAULT;
}
export function paletteAssetOf(project: Project, asset: PixelArtAsset): PaletteAsset | null {
return findAssetOf(project, textOf(asset.props[PALETTE_KEY] ?? ''), 'palette') ?? null;
}
/** The colours the asset is painted in; the default ones while its row names no palette. */
export function paletteColors(project: Project | undefined, asset: PixelArtAsset): readonly number[] {
return (project && paletteAssetOf(project, asset)?.colors) ?? DEFAULT_PALETTE;
}
/** The colour of an index, or null for transparent – 0, or an index past the end of the palette. */
export function colorAt(colors: readonly number[], index: number): number | null {
return index > 0 && index <= colors.length ? colors[index - 1]! : null;
}
export function findLayer(asset: PixelArtAsset, layerId: string): PixelLayer | undefined {
return asset.layers.find((layer) => layer.id === layerId);
}
export function frameIndexOf(asset: PixelArtAsset, frameId: string): number {
return asset.frames.findIndex((frame) => frame.id === frameId);
}
/** The cel file of a layer on a frame, `''` when that layer is empty there. */
export function celOf(frame: PixelFrame, layerId: string): string {
return frame.cels[layerId] ?? '';
}
/** Every cel file the asset names, each once. */
export function celFilesOf(asset: PixelArtAsset): string[] {
const files = new Set<string>();
for (const frame of asset.frames) for (const file of Object.values(frame.cels)) if (file) files.add(file);
return [...files];
}
export function frameLabel(index: number): string {
return `Frame ${index + 1}`;
}
/** Reads a cel: the pixels, null for an empty cel, undefined while it is still loading. */
export type CelReader = (file: string) => CelPixels | null | undefined;
/**
* One frame as RGBA, the layers laid over each other from the first to the last. A cel of another
* size than the canvas (it cannot be written that way, but a file is a file) is read from its
* top-left corner. Null while any visible cel is still loading – half a picture is not drawn.
*/
export function composeFrame(
asset: PixelArtAsset,
colors: readonly number[],
frame: PixelFrame,
read: CelReader,
): Uint8ClampedArray<ArrayBuffer> | null {
const { width, height } = asset;
const red = new Float32Array(width * height);
const green = new Float32Array(width * height);
const blue = new Float32Array(width * height);
const alpha = new Float32Array(width * height);
for (const layer of asset.layers) {
if (layer.hidden || layer.opacity <= 0) continue;
const file = celOf(frame, layer.id);
if (!file) continue;
const cel = read(file);
if (cel === undefined) return null;
if (cel === null) continue;
const opacity = Math.min(1, layer.opacity);
const w = Math.min(width, cel.width);
const h = Math.min(height, cel.height);
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) {
const color = colorAt(colors, cel.data[y * cel.width + x]!);
if (color === null) continue;
const i = y * width + x;
const below = alpha[i]! * (1 - opacity);
const out = opacity + below;
red[i] = (((color >> 16) & 0xff) * opacity + red[i]! * below) / out;
green[i] = (((color >> 8) & 0xff) * opacity + green[i]! * below) / out;
blue[i] = ((color & 0xff) * opacity + blue[i]! * below) / out;
alpha[i] = out;
}
}
const rgba = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < width * height; i++) {
rgba[i * 4] = Math.round(red[i]!);
rgba[i * 4 + 1] = Math.round(green[i]!);
rgba[i * 4 + 2] = Math.round(blue[i]!);
rgba[i * 4 + 3] = Math.round(alpha[i]! * 255);
}
return rgba;
}
/** Every frame side by side, as the generated picture holds them; null while a cel is loading. */
export function composeSheet(
asset: PixelArtAsset,
colors: readonly number[],
read: CelReader,
): Uint8ClampedArray<ArrayBuffer> | null {
const { width, height } = asset;
const sheetWidth = width * asset.frames.length;
const sheet = new Uint8ClampedArray(sheetWidth * height * 4);
for (const [index, frame] of asset.frames.entries()) {
const rgba = composeFrame(asset, colors, frame, read);
if (!rgba) return null;
for (let y = 0; y < height; y++)
sheet.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), (y * sheetWidth + index * width) * 4);
}
return sheet;
}
/**
* The token that says which picture this is (#185): canvas, colours, the layers as drawn and the cels
* of every frame. Cel names are content hashes, so an edited pixel is a new name and a new token. A
* layer's name is left out, as a shape's is – renaming changes no pixel.
*/
export function pixelArtRevision(asset: PixelArtAsset, colors: readonly number[]): string {
const layers = asset.layers.map((l) => `${l.id}:${l.hidden ? 'h' : 'v'}:${l.opacity}`);
const frames = asset.frames.map((frame) =>
asset.layers.map((layer) => celOf(frame, layer.id).slice(0, 16)).join(','),
);
return fingerprint(
`${asset.width}x${asset.height}|${colors.join(',')}|${layers.join(';')}|${frames.join('/')}`,
);
}
function fingerprint(text: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(36);
}
/** What the picture path is told (#185): the sheet of all frames side by side. */
export function pixelArtPicture(asset: Asset, project?: Project): GeneratedPicture | null {
if (asset.type !== 'pixelart' || asset.frames.length === 0) return null;
return {
width: asset.width * asset.frames.length,
height: asset.height,
revision: pixelArtRevision(asset, paletteColors(project, asset)),
};
}
export interface PixelFrameRef {
asset: PixelArtAsset;
frame: PixelFrame;
index: number;
}
/** A frame id of any pixel art asset, the way `findFrame` finds one of a texture. */
export function findPixelFrame(project: Project, frameId: string): PixelFrameRef | null {
if (!frameId) return null;
for (const asset of project.assets) {
if (asset.type !== 'pixelart') continue;
const index = frameIndexOf(asset, frameId);
if (index >= 0) return { asset, frame: asset.frames[index]!, index };
}
return null;
}
/** Where a frame lies in the sheet. */
export function pixelFrameRect(
asset: PixelArtAsset,
index: number,
): { x: number; y: number; w: number; h: number } {
return { x: index * asset.width, y: 0, w: asset.width, h: asset.height };
}
/**
* Every id that means "this picture" (the whole sheet and each frame), as `textureRefIds` does for a
* texture: what counting the uses of the asset has to look for.
*/
export function pixelArtRefIds(asset: PixelArtAsset): string[] {
return [asset.id, ...asset.frames.map((frame) => frame.id)];
}
/** What a sprite made from the asset should wear: the first frame of a sequence, else the picture. */
export function pixelArtWornId(asset: PixelArtAsset): string {
return asset.frames.length > 1 ? asset.frames[0]!.id : asset.id;
}
export function isPixelArt(asset: Asset | undefined | null): asset is PixelArtAsset {
return asset?.type === 'pixelart';
}
export function findPixelArt(project: Project, assetId: string): PixelArtAsset | null {
const asset = findAsset(project, assetId);
return isPixelArt(asset) ? asset : null;
}
+9 -5
View File
@@ -1,5 +1,6 @@
import type { Asset, FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
import { generatedKey, generatedPictureOf } from './generated';
import { findPixelFrame, pixelFrameRect } from './pixelart';
import { type PropertyBag, textOf, type Vec2 } from './Properties';
import { findAsset, type Project } from './Project';
@@ -92,21 +93,24 @@ export function resolvePicture(project: Project, id: string): TextureRef | null
const asset = findAsset(project, id);
if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset, project);
const found = findFrame(project, id);
return found ? refFor(found.asset, found.frame) : null;
if (found) return refFor(found.asset, found.frame);
// A frame of pixel art (#130) is a cut of its generated sheet, as a texture frame is of its image.
const pixel = findPixelFrame(project, id);
return pixel ? generatedRef(pixel.asset, project, pixelFrameRect(pixel.asset, pixel.index)) : null;
}
/**
* An asset that draws its own picture (#185) reads like a texture of its canvas size: the key
* stands where the file name stands, and the size is what a new sprite is measured by. No frames
* and no slicing – those are cut out of an uploaded sheet.
* stands where the file name stands, and the size is what a new sprite is measured by. No slicing –
* that is cut out of an uploaded sheet; a frame only where the drawing has frames (pixel art).
*/
function generatedRef(asset: Asset, project: Project): TextureRef | null {
function generatedRef(asset: Asset, project: Project, rect: FrameRect | null = null): TextureRef | null {
const picture = generatedPictureOf(asset, project);
if (!picture) return null;
return {
file: generatedKey(asset.id, picture.revision),
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
rect: null,
rect,
sourceWidth: picture.width,
sourceHeight: picture.height,
slice: null,