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

+13
View File
@@ -3,6 +3,7 @@ import { newId } from '../ids';
import type { Asset, FontAsset, SoundAsset, StateMachineAsset, TextureAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
import { isSoundFile } from '../model/sound';
import { removeSubtrees } from '../model/hierarchy';
import type { PropertyBag, PropertyValue } from '../model/Properties';
@@ -42,6 +43,18 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
// A drawing starts empty: the pen tool of #129 sets the first shape down.
case 'vectorart':
return { ...base, type: 'vectorart', shapes: [] };
// A canvas with nothing to paint on is useless, so it starts with one layer on one frame.
case 'pixelart':
return {
...base,
type: 'pixelart',
width: PIXEL_CANVAS_DEFAULT,
height: PIXEL_CANVAS_DEFAULT,
layers: [{ id: newId(), name: 'Layer 1', opacity: 1 }],
frames: [{ id: newId(), cels: {} }],
};
case 'palette':
return { ...base, type: 'palette', colors: [...DEFAULT_PALETTE] };
case 'animation':
return { ...base, type: 'animation', root: '', tracks: [] };
case 'tile':
+85
View File
@@ -0,0 +1,85 @@
/**
* Editing pixel art and palettes (#130). Palette and filter of a pixel art asset are ordinary
* properties and go through `SetAssetPropertyCommand`; the layers, frames and cels are intrinsic
* data. Every edit of those is a function from one state to the next (`model/pixelEdit.ts`), and
* this one command runs it and keeps the state from before for undo – a stroke, a new layer and a
* resized canvas all take the same way back.
*/
import type { PaletteAsset, PixelArtAsset } from '../model/Asset';
import { PALETTE_MAX } from '../model/pixelart';
import { applyState, type PixelArtState, PixelEditError, pixelArtState } from '../model/pixelEdit';
import { findAssetOf, type Project } from '../model/Project';
import { type Command, CommandError } from './Command';
function requirePixelArt(project: Project, assetId: string): PixelArtAsset {
const asset = findAssetOf(project, assetId, 'pixelart');
if (!asset) throw new CommandError(`Pixel art ${assetId} not found`);
return asset;
}
function requirePalette(project: Project, assetId: string): PaletteAsset {
const asset = findAssetOf(project, assetId, 'palette');
if (!asset) throw new CommandError(`Palette ${assetId} not found`);
return asset;
}
/**
* Runs one edit on the asset's state. The edit is worked out when the command runs, against the
* state as it is then, so a redo lands where the first run did; the ids it hands out are fixed
* beforehand by whoever builds it.
*/
export class EditPixelArtCommand implements Command {
private before: PixelArtState | null = null;
constructor(
private readonly assetId: string,
readonly label: string,
private readonly edit: (state: PixelArtState) => PixelArtState,
) {}
execute(project: Project): void {
const asset = requirePixelArt(project, this.assetId);
const before = pixelArtState(asset);
let after: PixelArtState;
try {
after = this.edit(before);
} catch (error) {
if (error instanceof PixelEditError) throw new CommandError(error.message);
throw error;
}
this.before = before;
applyState(asset, after);
}
undo(project: Project): void {
if (this.before) applyState(requirePixelArt(project, this.assetId), this.before);
}
}
/**
* The colours of a palette, all of them at once. Index 0 is transparent and never stored, so a
* palette may be empty, and holds at most `PALETTE_MAX`.
*/
export class SetPaletteColorsCommand implements Command {
private before: number[] = [];
constructor(
private readonly paletteId: string,
readonly label: string,
private readonly colors: readonly number[],
) {}
execute(project: Project): void {
const palette = requirePalette(project, this.paletteId);
if (this.colors.length > PALETTE_MAX)
throw new CommandError(`A palette holds at most ${PALETTE_MAX} colours`);
if (!this.colors.every((c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff))
throw new CommandError('A colour is 0xRRGGBB');
this.before = [...palette.colors];
palette.colors = [...this.colors];
}
undo(project: Project): void {
requirePalette(project, this.paletteId).colors = [...this.before];
}
}
+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,
+18
View File
@@ -35,6 +35,7 @@ import {
TILE_SOLID_KEY,
} from '../model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
import { PALETTE_KEY } from '../model/pixelart';
import { SLICE_SCALE_KEY } from '../model/texture';
import {
VECTOR_CANVAS_DEFAULT,
@@ -319,6 +320,23 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
},
],
// Layers, frames and the canvas size are intrinsic data on the asset (see PixelArtAsset). A palette
// is shared, so the asset names one; the filter is the texture's row, sharp by default for pixels.
pixelart: [
{ key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] },
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'nearest',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
],
// The colours are intrinsic data on the asset (see PaletteAsset).
palette: [],
// The root and the rows are intrinsic data on the asset (see AnimationAsset).
animation: [
{ key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 },
+15 -3
View File
@@ -1,5 +1,6 @@
import type { Asset, TextureFrame } from '../model/Asset';
import type { Asset, PixelFrame, TextureFrame } from '../model/Asset';
import { generatesPictures } from '../model/generated';
import { findPixelFrame, frameLabel } from '../model/pixelart';
import { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture';
@@ -14,7 +15,7 @@ export interface TextureChoice {
/** The texture, or the asset that draws its own picture (#185). */
asset: Asset;
/** Null for the whole-picture entry. */
frame: TextureFrame | null;
frame: TextureFrame | PixelFrame | null;
}
/** An asset a texture field may name: an uploaded picture or a drawn one. */
@@ -33,7 +34,9 @@ export function textureRefLabel(project: Project | null, id: string): string {
const asset = findAsset(project, id);
if (asset) return isPictureAsset(asset) ? asset.name : `${id}?`;
const found = findFrame(project, id);
return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`;
if (found) return `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}`;
const pixel = findPixelFrame(project, id);
return pixel ? `${pixel.asset.name}${FRAME_SEPARATOR}${frameLabel(pixel.index)}` : `${id}?`;
}
/**
@@ -46,6 +49,15 @@ export function pictureChoices(project: Project): TextureChoice[] {
const choices: TextureChoice[] = [];
for (const asset of pictures) {
choices.push({ id: asset.id, label: asset.name, asset, frame: null });
// Pixel art lists its frames once there is more than one – with one, the frame is the picture.
if (asset.type === 'pixelart' && asset.frames.length > 1)
for (const [index, frame] of asset.frames.entries())
choices.push({
id: frame.id,
label: `${asset.name}${FRAME_SEPARATOR}${frameLabel(index)}`,
asset,
frame,
});
if (asset.type !== 'texture') continue;
for (const frame of asset.frames)
choices.push({
+5 -2
View File
@@ -1,8 +1,11 @@
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
import { apiRequest } from './http';
/** The API's three file collections, one route each; textures, fonts and sounds differ in nothing else. */
export type FileRoute = 'textures' | 'fonts' | 'sounds';
/**
* The API's file collections, one route each; textures, fonts and sounds differ in nothing else.
* Pixels (#130) are only ever uploaded – a cel is found by the name in the project, never listed.
*/
export type FileRoute = 'textures' | 'fonts' | 'sounds' | 'pixels';
export class HttpFileStore<
U extends UploadedFile = UploadedFile,
+70
View File
@@ -23,6 +23,8 @@ import type {
AnimationKey,
AnimationTrack,
Asset,
PixelFrame,
PixelLayer,
StateMachineAsset,
TextureFrame,
GradientStop,
@@ -61,6 +63,9 @@ import {
setFrameSlice,
} from '../model/texture';
import { pointsOf } from '../model/polygon';
import { canvasSide, PALETTE_MAX } from '../model/pixelart';
import { isCelFile } from '../model/pixelCel';
import { newId } from '../ids';
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { hasParentCycle } from '../model/walk';
import {
@@ -200,6 +205,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
} else if (type === 'vectorart') {
assets.push({ id, type, name, props, shapes: normalizeShapes(a.shapes) });
} else if (type === 'pixelart') {
assets.push({ id, type, name, props, ...normalizePixelArt(a, takenIds) });
} else if (type === 'palette') {
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
} else if (type === 'animation') {
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
const root = typeof a.root === 'string' ? a.root : '';
@@ -454,6 +463,67 @@ function normalizeAnnotations(raw: unknown, takenIds: Set<string>): GraphAnnotat
return list;
}
/**
* Layers need a unique id and keep their order; a frame id shares the reference space with asset ids,
* as a texture frame's does. A cel naming a layer that is gone, or no cel file, falls away. An asset
* left without a layer or a frame gets an empty one, the way a new asset starts.
*/
function normalizePixelArt(
raw: Record<string, unknown>,
takenIds: Set<string>,
): { width: number; height: number; layers: PixelLayer[]; frames: PixelFrame[] } {
const layers: PixelLayer[] = [];
const layerIds = new Set<string>();
for (const item of Array.isArray(raw.layers) ? raw.layers : []) {
const l = asRecord(item);
const id = typeof l.id === 'string' ? l.id : '';
if (!id || layerIds.has(id)) continue;
layerIds.add(id);
const opacity = asFinite(l.opacity);
const layer: PixelLayer = {
id,
name: typeof l.name === 'string' ? l.name : '',
opacity: opacity === null ? 1 : Math.max(0, Math.min(1, opacity)),
};
if (l.hidden === true) layer.hidden = true;
layers.push(layer);
}
if (layers.length === 0) {
const layer = { id: newId(), name: 'Layer 1', opacity: 1 };
layers.push(layer);
layerIds.add(layer.id);
}
const frames: PixelFrame[] = [];
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
const f = asRecord(item);
const id = typeof f.id === 'string' ? f.id : '';
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const cels: Record<string, string> = {};
for (const [layerId, file] of Object.entries(asRecord(f.cels)))
if (layerIds.has(layerId) && typeof file === 'string' && isCelFile(file)) cels[layerId] = file;
frames.push({ id, cels });
}
if (frames.length === 0) {
let id = newId();
while (takenIds.has(id)) id = newId();
takenIds.add(id);
frames.push({ id, cels: {} });
}
return { width: canvasSide(raw.width), height: canvasSide(raw.height), layers, frames };
}
/** Colours as 0xRRGGBB, at most `PALETTE_MAX`; anything that is no colour falls away. */
function normalizePalette(raw: unknown): number[] {
const colors: number[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const color = asFinite(item);
if (color !== null && colors.length < PALETTE_MAX)
colors.push(Math.max(0, Math.min(0xffffff, Math.floor(color))));
}
return colors;
}
/**
* A file name that is not the server's content-addressed form is dropped: a request built from it
* would 404, and the texture then loads as "no picture".
+11 -1
View File
@@ -10,6 +10,8 @@ import { fontLibrary } from '../viewport/FontLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { projectPictureFactory } from '../viewport/generatedPictures';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installPixelArtPictures } from '../viewport/pixelArtPicture';
import { pixelCels } from '../viewport/PixelCels';
import { installVectorArtPictures } from '../viewport/vectorArtPicture';
import { LoginScreen } from './auth/LoginScreen';
import { installLoginHook } from './auth/loginHook';
@@ -17,6 +19,7 @@ import { consoleLog } from './console/log';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { startPlugins } from './plugins/startPlugins';
import { CelUploadStore } from './pixelart/celUploadStore';
import { EditorState } from './state';
const API_ENDPOINT = '/game-maker/api/index.php';
@@ -28,6 +31,7 @@ const API_ENDPOINT = '/game-maker/api/index.php';
const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts';
const SOUND_BASE = '/game-maker/data/sounds';
const PIXEL_BASE = '/game-maker/data/pixels';
/**
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
@@ -71,6 +75,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE);
soundLibrary.configure(SOUND_BASE);
pixelCels.configure(PIXEL_BASE);
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own. Registered once
// for the page, not per session – the files they hold are shared by every account anyway.
@@ -79,6 +84,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
// Vector art draws its own picture and needs the renderer to do it (#129). Registered once for
// the page like the libraries above: which project it is asked about comes from the factory below.
installVectorArtPictures(app.renderer);
installPixelArtPictures();
const openSession = async (): Promise<void> => {
const user = await signedInUser(app, root, auth);
@@ -93,7 +99,11 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
app,
root,
state,
new HttpProjectStore(API_ENDPOINT),
new CelUploadStore(
new HttpProjectStore(API_ENDPOINT),
pixelCels,
new HttpFileStore(API_ENDPOINT, 'pixels'),
),
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'),
+9
View File
@@ -54,6 +54,7 @@ import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { PlayFrom } from './settings';
import type { EditorState } from './state';
import { PixelArtEditor } from './pixelart/PixelArtEditor';
import { VectorArtEditor } from './vectorart/VectorArtEditor';
import { TextureEditor } from './texture/TextureEditor';
import { installFileImport } from './fileImport';
@@ -69,6 +70,7 @@ export type CentreTab =
| 'game'
| 'texture'
| 'vectorart'
| 'pixelart'
| 'animation'
| 'tile'
| 'container'
@@ -108,6 +110,7 @@ const PANE_ORDER = [
'particles',
'texture',
'vectorart',
'pixelart',
'animation',
'tile',
'container',
@@ -125,6 +128,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
particles: 'Particles',
texture: 'Texture',
vectorart: 'Vector art',
pixelart: 'Pixel art',
animation: 'Animation',
tile: 'Tile',
container: 'Container',
@@ -169,6 +173,7 @@ export class Editor implements PlayHost {
readonly particlesEditor: ParticlesEditor;
readonly textureEditor: TextureEditor;
readonly vectorArtEditor: VectorArtEditor;
readonly pixelArtEditor: PixelArtEditor;
readonly animationEditor: AnimationEditor;
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
@@ -240,6 +245,7 @@ export class Editor implements PlayHost {
this.particlesEditor = new ParticlesEditor(state);
this.textureEditor = new TextureEditor(state);
this.vectorArtEditor = new VectorArtEditor(state);
this.pixelArtEditor = new PixelArtEditor(state);
this.animationEditor = new AnimationEditor(state);
this.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state);
@@ -331,6 +337,7 @@ export class Editor implements PlayHost {
particles: this.particlesEditor,
texture: this.textureEditor,
vectorart: this.vectorArtEditor,
pixelart: this.pixelArtEditor,
animation: this.animationEditor,
tile: this.tileEditor,
container: this.containerEditor,
@@ -716,6 +723,7 @@ export class Editor implements PlayHost {
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
if (this.tab === 'particles') this.particlesEditor.tick();
if (this.tab === 'shader') this.shaderEditor.tick();
if (this.tab === 'pixelart') this.pixelArtEditor.tick();
if (this.movesByItself()) requestRender();
}
@@ -730,6 +738,7 @@ export class Editor implements PlayHost {
(this.viewport.visible && this.viewport.animates) ||
(this.gameView.visible && this.gameView.animates) ||
this.tab === 'particles' ||
this.tab === 'pixelart' ||
(this.tab === 'shader' && this.shaderEditor.animates) ||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
(this.tab === 'tile' && this.tileEditor.isPlaying)
+7 -1
View File
@@ -16,6 +16,7 @@ import type { Command } from '../core/commands/Command';
import { findAnnotation } from '../core/graph';
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
import { pixelArtRefIds } from '../core/model/pixelart';
import { textureRefIds } from '../core/model/texture';
import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../core/model/usages';
import { Dialog } from '../ui';
@@ -108,7 +109,12 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const usages = findUsages(state.project, assetId).length;
// A texture is never placed but worn through a property, so counting placed instances would
// report "unused". Its frames count as the texture itself.
const ids = asset.type === 'texture' ? textureRefIds(asset) : [assetId];
const ids =
asset.type === 'texture'
? textureRefIds(asset)
: asset.type === 'pixelart'
? pixelArtRefIds(asset)
: [assetId];
const references = findPropertyUsages(state.project, ids).length;
// An asset that draws its own picture (#185) is worn the same way, only without frames.
const picture = asset.type === 'texture' || generatesPictures(asset.type);
+1
View File
@@ -10,6 +10,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
particles: 'particles',
texture: 'texture',
vectorart: 'vectorart',
pixelart: 'pixelart',
animation: 'animation',
tile: 'tile',
container: 'container',
+103
View File
@@ -20,6 +20,7 @@ import {
Widget,
} from '../ui';
import { particleLibrary } from '../viewport/ParticleLibrary';
import { pixelCels } from '../viewport/PixelCels';
import { shaderLibrary } from '../viewport/ShaderLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
@@ -32,6 +33,7 @@ import { currentNodePalette } from './graph/NodePalette';
import { currentSoundPicker } from './sound/SoundPicker';
import { currentImagePicker } from './texture/ImagePicker';
import { currentTilemapWidgets } from './panels/TilemapPanel';
import { currentResizeDialog } from './pixelart/ResizeCanvasDialog';
import { currentGradientBar, currentShapeList } from './inspector/shapeSection';
import type { SnapToggle } from './graph/SnapToggle';
import type { EditorState, TilemapTool } from './state';
@@ -478,6 +480,107 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
generateDialog: () => textureEditor.generateDialog.current()?.info ?? null,
},
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
/** The Pixel art tab (#130): where its controls are, what it paints with, and the asset's pixels. */
pixelArt: {
current: () => editor.pixelArtEditor.currentAssetId,
info: () => editor.pixelArtEditor.shownInfo,
/** Where the centre of canvas pixel (x, y) is on screen. */
pixelAt: (x: number, y: number) => editor.pixelArtEditor.canvas.pixelToScreen(x, y),
zoom: () => editor.pixelArtEditor.canvas.zoom,
tool: () => editor.pixelArtEditor.canvas.tool,
color: () => editor.pixelArtEditor.canvas.color,
frameId: () => editor.pixelArtEditor.canvas.frameId,
layerId: () => editor.pixelArtEditor.canvas.layerId,
/** A control of the side panel or the header, scrolled into view: its centre and whether it is enabled. */
control: (name: string) => {
const side = editor.pixelArtEditor.side;
const byName: Record<string, Widget | undefined> = {
brush: side.brushButton,
filled: side.filledBox,
contiguous: side.contiguousBox,
color: side.colorField,
addColor: side.addColorButton,
removeColor: side.removeColorButton,
createPalette: side.createPaletteButton,
layerName: side.layerName,
layerOpacity: side.layerOpacity,
onion: side.onionBox,
tiles: side.tilesBox,
fps: side.fpsField,
palette: side.palette,
preview: side.preview,
resize: editor.pixelArtEditor.resizeButton,
...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])),
...Object.fromEntries(Object.entries(side.layerButtons).map(([k, b]) => [`layer:${k}`, b])),
...Object.fromEntries(Object.entries(side.frameButtons).map(([k, b]) => [`frame:${k}`, b])),
};
const widget = byName[name];
if (!widget || !widget.visible) return null;
if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget);
return {
...centreOf(widget),
enabled: widget.enabled,
text: widget instanceof Button ? widget.text : '',
};
},
swatch: (index: number) => {
const side = editor.pixelArtEditor.side;
side.reveal(side.palette);
return side.palette.swatchGlobal(index);
},
layerRow: (layerId: string) => {
const side = editor.pixelArtEditor.side;
side.reveal(side.layers);
return side.layers.rowGlobal(layerId);
},
frameRow: (frameId: string) => {
const side = editor.pixelArtEditor.side;
side.reveal(side.frames);
return side.frames.rowGlobal(frameId);
},
previewIndex: () => editor.pixelArtEditor.side.preview.shownIndex,
/** Canvas, layers and frames as the model holds them. */
asset: (assetId: string) => {
const asset = findAsset(state.project, assetId);
return asset?.type === 'pixelart'
? (JSON.parse(
JSON.stringify({
width: asset.width,
height: asset.height,
layers: asset.layers,
frames: asset.frames,
props: asset.props,
}),
) as unknown)
: null;
},
/** The palette indices of a cel, row by row; null for an empty cel, 'loading' while it loads. */
cel: (file: string) => {
const cel = pixelCels.read(file);
return cel === undefined
? 'loading'
: cel === null
? null
: { width: cel.width, height: cel.height, data: [...cel.data] };
},
/** Cels painted here that the server does not hold yet. */
pending: () => pixelCels.pending(state.project).length,
resizeDialog: () => {
const dialog = currentResizeDialog();
if (!dialog) return null;
return {
...dialog.debugInfo(),
width: centreOf(dialog.widthField),
height: centreOf(dialog.heightField),
confirm: centreOf(dialog.confirm),
anchors: Object.fromEntries(
([0, 0.5, 1] as const).flatMap((y) =>
([0, 0.5, 1] as const).map((x) => [`${x},${y}`, dialog.anchorButton(x, y)]),
),
),
};
},
},
vectorArt: {
current: () => vectorArtEditor.currentAssetId,
info: () => vectorArtEditor.shownInfo,
+30 -7
View File
@@ -11,8 +11,9 @@ import {
import { oneStep } from '../../core/commands/CompoundCommand';
import { nextAssetName } from '../../core/commands/naming';
import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes';
import type { TextureAsset } from '../../core/model/Asset';
import type { PixelArtAsset, TextureAsset } from '../../core/model/Asset';
import { generatesPictures } from '../../core/model/generated';
import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { spriteFromTexture } from '../../core/properties/aspect';
@@ -195,17 +196,17 @@ export class AssetPanel extends Panel {
* A sprite wearing the texture and an animation over it whose one row keys the frames in order,
* one per frame – what a picture sequence is on a timeline. One undo step for all of it.
*/
private createAnimation(texture: TextureAsset): void {
private createAnimation(picture: TextureAsset | PixelArtAsset): void {
const project = this.state.project;
const pictures = picture.frames.length > 0 ? picture.frames.map((f) => f.id) : [picture.id];
const sprite = new CreateAssetCommand(
'sprite',
nextAssetName(project, texture.name),
spriteFromTexture(project, texture.id),
nextAssetName(project, picture.name),
spriteFromTexture(project, pictures[0] ?? picture.id),
);
const create = new CreateAssetCommand('animation', nextAssetName(project, texture.name));
const create = new CreateAssetCommand('animation', nextAssetName(project, picture.name));
const root = new SetAnimationRootCommand(create.asset.id, sprite.asset.id);
const row = new AddAnimationTrackCommand(create.asset.id, '', 'texture');
const pictures = texture.frames.length > 0 ? texture.frames.map((f) => f.id) : [texture.id];
const keys = pictures.map(
(id, frame) => new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, id),
);
@@ -222,9 +223,27 @@ export class AssetPanel extends Panel {
if (type === 'texture') this.importTextures();
else if (type === 'font') this.importFonts();
else if (type === 'sound') this.importSounds();
else if (type === 'pixelart') this.createPixelArt();
else this.createAsset(type);
}
/**
* Pixel art is painted in a palette the project shares (#130, answer 2): a new one takes the first
* palette there is, and where there is none a palette is created along with it, in one undo step.
*/
private createPixelArt(): void {
const project = this.state.project;
const existing = groupAssets(project.assets).find((group) => group.type === 'palette')?.assets[0];
const palette = existing
? null
: new CreateAssetCommand('palette', nextAssetName(project, ASSET_TYPE_LABELS.palette));
const art = new CreateAssetCommand('pixelart', nextAssetName(project, ASSET_TYPE_LABELS.pixelart), {
[PALETTE_KEY]: existing?.id ?? palette?.asset.id ?? '',
});
const commands = palette ? [palette, art] : [art];
if (this.state.execute(oneStep(art.label, commands))) this.state.openAsset(art.asset.id);
}
private importTextures(): void {
if (!this.textures) {
this.state.setNotice('Image upload is not available.');
@@ -309,12 +328,16 @@ export class AssetPanel extends Panel {
// A drawn picture (#185) is worn like a texture, so it makes a sprite the same way. Only the
// animation needs frames, which a drawn picture has none of.
if (asset.type === 'texture' || generatesPictures(asset.type)) {
// A sequence of pixel art frames makes a sprite of its first frame rather than of the strip.
const worn = asset.type === 'pixelart' ? pixelArtWornId(asset) : id;
items.push({
label: asset.type === 'texture' ? 'Create sprite from texture' : 'Create sprite from picture',
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, id)),
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, worn)),
});
if (asset.type === 'texture')
items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) });
if (asset.type === 'pixelart' && asset.frames.length > 1)
items.push({ label: 'Create animation from frames', onSelect: () => this.createAnimation(asset) });
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
const menu = new Menu(items);
+3
View File
@@ -19,6 +19,9 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
// Not "Vector art": that is the `+` menu entry, and a heading of the same words would take the
// click meant for the menu (see the test below the labels).
vectorart: 'Vector drawings',
// Not "Pixel art" either, for the same reason.
pixelart: 'Pixel drawings',
palette: 'Palettes',
animation: 'Animations',
tile: 'Tiles',
tilemap: 'Tilemaps',
+4
View File
@@ -11,7 +11,9 @@ import container from './icons/container.svg?raw';
import font from './icons/font.svg?raw';
import game from './icons/game.svg?raw';
import list from './icons/list.svg?raw';
import palette from './icons/palette.svg?raw';
import particles from './icons/particles.svg?raw';
import pixelart from './icons/pixelart.svg?raw';
import plugins from './icons/plugins.svg?raw';
import rigidbody from './icons/rigidbody.svg?raw';
import scene from './icons/scene.svg?raw';
@@ -33,7 +35,9 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
collider,
container,
font,
palette,
particles,
pixelart,
rigidbody,
scene,
shader,
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- Four swatches in a grid: the colours a picture may be painted with. -->
<rect x="-6.5" y="-6.5" width="5.5" height="5.5" fill="currentColor" />
<rect x="1" y="-6.5" width="5.5" height="5.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="-6.5" y="1" width="5.5" height="5.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="1" y="1" width="5.5" height="5.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 522 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- A pixel heart: a picture made of square pixels, nothing smooth about it. -->
<path d="M-5 -6 H-1 V-4 H1 V-6 H5 V-4 H7 V0 H5 V2 H3 V4 H1 V6 H-1 V4 H-3 V2 H-5 V0 H-7 V-4 H-5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

+79
View File
@@ -0,0 +1,79 @@
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
import { type Size, Theme, Widget } from '../../ui';
const CELL = 20;
const GAP = 2;
export const PALETTE_COLUMNS = 8;
/**
* The colours of a palette as swatches, eight to a row (#130). The first swatch is index 0 –
* transparent, drawn as a checkerboard – and the others follow the palette in order, so a swatch's
* place is its index. A click picks it; the picked one is outlined.
*/
export class PaletteGrid extends Widget {
private readonly swatches = new Graphics();
private colors: readonly number[] = [];
private picked = 1;
onPick: (index: number) => void = () => {};
constructor() {
super();
this.addChild(this.swatches);
this.cursor = 'pointer';
this.on('pointerdown', (e: FederatedPointerEvent) => {
const local = this.toLocal(e.global);
const column = Math.floor(local.x / (CELL + GAP));
const row = Math.floor(local.y / (CELL + GAP));
const index = row * PALETTE_COLUMNS + column;
if (column < PALETTE_COLUMNS && index <= this.colors.length) this.onPick(index);
e.stopPropagation();
});
}
setColors(colors: readonly number[], picked: number): void {
const grew =
Math.ceil((colors.length + 1) / PALETTE_COLUMNS) !==
Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS);
this.colors = colors;
this.picked = picked;
if (grew) (this.parent as Widget | null)?.invalidate();
this.invalidate();
}
override measure(): Size {
const rows = Math.max(1, Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS));
return { w: PALETTE_COLUMNS * (CELL + GAP), h: rows * (CELL + GAP) };
}
/** Where a swatch's centre sits on screen – the check scripts click it. */
swatchGlobal(index: number): { x: number; y: number } {
const p = this.getGlobalPosition();
const at = this.cellAt(index);
return { x: p.x + at.x + CELL / 2, y: p.y + at.y + CELL / 2 };
}
private cellAt(index: number): { x: number; y: number } {
return {
x: (index % PALETTE_COLUMNS) * (CELL + GAP),
y: Math.floor(index / PALETTE_COLUMNS) * (CELL + GAP),
};
}
override redraw(): void {
const g = this.swatches.clear();
for (let index = 0; index <= this.colors.length; index++) {
const { x, y } = this.cellAt(index);
if (index === 0) {
g.rect(x, y, CELL, CELL).fill(0x9aa0aa);
g.rect(x, y, CELL / 2, CELL / 2)
.rect(x + CELL / 2, y + CELL / 2, CELL / 2, CELL / 2)
.fill(0x5a5f69);
} else g.rect(x, y, CELL, CELL).fill(this.colors[index - 1] ?? 0);
g.rect(x + 0.5, y + 0.5, CELL - 1, CELL - 1).stroke({ width: 1, color: Theme.colors.border });
}
if (this.picked >= 0 && this.picked <= this.colors.length) {
const { x, y } = this.cellAt(this.picked);
g.rect(x - 1, y - 1, CELL + 2, CELL + 2).stroke({ width: 2, color: Theme.colors.accent });
}
}
}
+559
View File
@@ -0,0 +1,559 @@
/**
* The Pixel art tab's canvas (#130). It shows one frame of the asset – its layers composed exactly
* as the picture path composes them – and paints on one layer of it. A stroke works on a copy of that
* layer's pixels and shows it live; on release the copy becomes a cel file and one command puts it in
* place, so one stroke is one undo step (answer 9).
*
* The view zooms in whole steps only (1x to 32x) and pans by whole screen pixels, so every canvas
* pixel is a square of the same size on screen and the grid lines fall between them.
*/
import {
CanvasSource,
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
Sprite,
Text,
Texture,
} from 'pixi.js';
import { EditPixelArtCommand } from '../../core/commands/pixelArtCommands';
import type { PixelArtAsset, PixelFrame } from '../../core/model/Asset';
import {
celOf,
composeFrame,
findLayer,
frameIndexOf,
paletteColors,
type CelReader,
} from '../../core/model/pixelart';
import { type CelPixels, emptyCel } from '../../core/model/pixelCel';
import {
cloneCel,
ellipsePoints,
floodFill,
linePoints,
type PixelPoint,
pixelAt,
rectPoints,
stamp,
stampAll,
} from '../../core/model/pixelDraw';
import { withCels } from '../../core/model/pixelEdit';
import { findAsset } from '../../core/model/Project';
import { cancelFrameWork, onFrame, type Size, Theme, textStyle, ui, Widget } from '../../ui';
import { pixelCels } from '../../viewport/PixelCels';
import type { EditorState } from '../state';
import { drawChecker } from '../texture/checker';
import type { SheetView } from '../texture/sheetView';
import { centredView, fitZoom, GRID_FROM_ZOOM, pixelUnder, stepZoom } from './pixelView';
export type PixelTool = 'pencil' | 'eraser' | 'fill' | 'picker' | 'line' | 'rect' | 'ellipse';
export const PIXEL_TOOLS: readonly PixelTool[] = [
'pencil',
'eraser',
'fill',
'picker',
'line',
'rect',
'ellipse',
];
export const PIXEL_TOOL_LABELS: Record<PixelTool, string> = {
pencil: 'Pencil',
eraser: 'Eraser',
fill: 'Fill',
picker: 'Picker',
line: 'Line',
rect: 'Rectangle',
ellipse: 'Ellipse',
};
/** How strongly the neighbouring frames show through while onion skin is on. */
const ONION_ALPHA = 0.3;
/** A stroke's pixels stand in the frame under this name while it runs; no cel file looks like it. */
const WORKING = 'working:';
/** A stroke in progress: the layer's pixels as they were, and as the stroke has left them so far. */
interface Stroke {
tool: PixelTool;
frameId: string;
layerId: string;
base: CelPixels;
working: CelPixels;
start: PixelPoint;
last: PixelPoint;
}
/** One picture the canvas draws a frame into; reused as long as the canvas keeps its size. */
interface FramePicture {
canvas: OffscreenCanvas;
texture: Texture;
}
export class PixelArtCanvas extends Widget {
private readonly bg = new Graphics();
private readonly content = new Container();
private readonly checker = new Graphics();
private readonly onion = new Container();
private readonly tiles = new Container();
private readonly art = new Sprite();
private readonly grid = new Graphics();
private readonly overlay = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private readonly pictures = new Map<string, FramePicture>();
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
private assetId = '';
private needsFit = true;
private panning: { x: number; y: number; panX: number; panY: number } | null = null;
private stroke: Stroke | null = null;
private hover: PixelPoint | null = null;
/** Which frame and layer is being painted, per asset, so a tab switch comes back to them. */
private readonly places = new Map<string, { frameId: string; layerId: string }>();
private readonly stopCels: () => void;
private readonly frameKey = 'pixelart:stroke';
tool: PixelTool = 'pencil';
brush = 1;
/** Rectangles and ellipses are drawn filled rather than as an outline. */
filled = false;
/** Fill reaches only the area it is poured into; off, every pixel of that colour. */
contiguous = true;
/** The palette index painted with; 0 paints transparent. */
color = 1;
showOnion = false;
showTiles = false;
/** Told whenever something the side panel shows changes here: colour, frame, layer. */
onChange: () => void = () => {};
constructor(private readonly state: EditorState) {
super();
this.content.addChild(this.checker, this.onion, this.tiles, this.art, this.grid, this.overlay);
this.addChild(this.bg, this.content, this.hint, this.clip);
this.content.mask = this.clip;
this.content.eventMode = 'none';
this.hint.eventMode = 'none';
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('pointerleave', () => {
this.hover = null;
this.redraw();
});
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
state.on('load', () => {
this.places.clear();
this.needsFit = true;
this.redraw();
});
this.stopCels = pixelCels.onChange(() => this.redraw());
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stopCels();
for (const picture of this.pictures.values()) picture.texture.destroy(true);
super.destroy(options);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
show(assetId: string): void {
if (this.assetId === assetId) return;
this.cancelStroke();
this.assetId = assetId;
this.needsFit = true;
this.redraw();
this.onChange();
}
get currentAssetId(): string {
return this.assetId;
}
saveView(): { view: SheetView; needsFit: boolean } {
return { view: { ...this.view }, needsFit: this.needsFit };
}
restoreView(saved: unknown): void {
const { view, needsFit } = saved as { view: SheetView; needsFit: boolean };
if (needsFit) return;
this.view = { ...view };
this.needsFit = false;
this.redraw();
}
fit(): void {
this.needsFit = true;
this.redraw();
}
get zoom(): number {
return this.view.zoom;
}
asset(): PixelArtAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset?.type === 'pixelart' ? asset : null;
}
/** The frame being painted; the first one while none was picked or the picked one is gone. */
get frameId(): string {
const asset = this.asset();
if (!asset) return '';
const wanted = this.places.get(asset.id)?.frameId ?? '';
return frameIndexOf(asset, wanted) >= 0 ? wanted : (asset.frames[0]?.id ?? '');
}
/** The layer being painted; the top one while none was picked or the picked one is gone. */
get layerId(): string {
const asset = this.asset();
if (!asset) return '';
const wanted = this.places.get(asset.id)?.layerId ?? '';
return findLayer(asset, wanted) ? wanted : (asset.layers[asset.layers.length - 1]?.id ?? '');
}
selectFrame(frameId: string): void {
this.place({ frameId });
}
selectLayer(layerId: string): void {
this.place({ layerId });
}
private place(change: Partial<{ frameId: string; layerId: string }>): void {
if (!this.assetId) return;
this.cancelStroke();
this.places.set(this.assetId, { frameId: this.frameId, layerId: this.layerId, ...change });
this.redraw();
this.onChange();
}
setColor(index: number): void {
this.color = index;
this.onChange();
}
setTool(tool: PixelTool): void {
this.cancelStroke();
this.tool = tool;
this.redraw();
}
/** Where a canvas pixel's centre sits on screen – the check scripts aim with it. */
pixelToScreen(x: number, y: number): { x: number; y: number } {
const origin = this.getGlobalPosition();
return {
x: origin.x + this.view.panX + (x + 0.5) * this.view.zoom,
y: origin.y + this.view.panY + (y + 0.5) * this.view.zoom,
};
}
private frame(asset: PixelArtAsset): PixelFrame | undefined {
return asset.frames[frameIndexOf(asset, this.frameId)];
}
/** The pixels of a layer on the current frame: undefined while loading, empty where it has none. */
private layerPixels(asset: PixelArtAsset, layerId: string): CelPixels | undefined {
const frame = this.frame(asset);
if (!frame) return undefined;
const cel = pixelCels.read(celOf(frame, layerId));
return cel === null ? emptyCel(asset.width, asset.height) : cel;
}
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
if (!asset) return;
const local = this.toLocal(e.global);
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
this.panning = { x: local.x, y: local.y, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation();
return;
}
if (e.button !== 0) return;
ui().focus.focus(this);
e.stopPropagation();
const at = pixelUnder(this.view, local.x, local.y);
// Alt picks with any tool (answer 6), so the colour is at hand without leaving the pencil.
if (this.tool === 'picker' || e.altKey) {
this.pick(asset, at);
return;
}
const layer = findLayer(asset, this.layerId);
if (!layer) return;
if (layer.hidden) {
this.state.setNotice('The layer is hidden – show it to paint on it.');
return;
}
const pixels = this.layerPixels(asset, layer.id);
if (!pixels) {
this.state.setNotice('The pixels of this layer are still loading.');
return;
}
const base = cloneCel(pixels);
const stroke: Stroke = {
tool: this.tool,
frameId: this.frameId,
layerId: layer.id,
base,
working: cloneCel(base),
start: at,
last: at,
};
if (this.tool === 'fill') {
floodFill(stroke.working, at, this.color, this.contiguous);
this.commit(stroke);
return;
}
this.stroke = stroke;
this.paintTo(stroke, at);
this.redraw();
}
/** What the stroke does as the pointer reaches `at`: a trail for the brush, the whole shape anew for the rest. */
private paintTo(stroke: Stroke, at: PixelPoint): void {
const index = stroke.tool === 'eraser' ? 0 : this.color;
if (stroke.tool === 'pencil' || stroke.tool === 'eraser') {
stampAll(stroke.working, linePoints(stroke.last, at), this.brush, index);
stroke.last = at;
return;
}
stroke.working = cloneCel(stroke.base);
stroke.last = at;
const points =
stroke.tool === 'line'
? linePoints(stroke.start, at)
: stroke.tool === 'rect'
? rectPoints(stroke.start, at, this.filled)
: ellipsePoints(stroke.start, at, this.filled);
// A filled shape is filled pixel by pixel; the brush only widens a line or an outline.
if (this.filled && stroke.tool !== 'line') for (const p of points) stamp(stroke.working, p, 1, index);
else stampAll(stroke.working, points, this.brush, index);
}
/** The colour under the pointer: the top visible layer that paints the pixel, else transparent. */
private pick(asset: PixelArtAsset, at: PixelPoint): void {
for (const layer of [...asset.layers].reverse()) {
if (layer.hidden) continue;
const pixels = this.layerPixels(asset, layer.id);
const index = pixels ? pixelAt(pixels, at.x, at.y) : 0;
if (index !== 0) {
this.setColor(index);
return;
}
}
this.setColor(0);
}
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.panning) {
this.view = {
...this.view,
panX: Math.round(this.panning.panX + local.x - this.panning.x),
panY: Math.round(this.panning.panY + local.y - this.panning.y),
};
this.needsFit = false;
this.redraw();
return;
}
const at = pixelUnder(this.view, local.x, local.y);
const stroke = this.stroke;
if (stroke) {
if (at.x !== stroke.last.x || at.y !== stroke.last.y) {
this.paintTo(stroke, at);
onFrame(this.frameKey, () => this.redraw());
}
return;
}
const hover = this.hover;
if (!hover || hover.x !== at.x || hover.y !== at.y) {
this.hover = at;
this.redraw();
}
}
private onPointerUp(): void {
this.panning = null;
const stroke = this.stroke;
this.stroke = null;
cancelFrameWork(this.frameKey);
if (stroke) this.commit(stroke);
}
private cancelStroke(): void {
this.stroke = null;
cancelFrameWork(this.frameKey);
}
/** The stroke's pixels become a cel file, and one command puts it in place – nothing when nothing changed. */
private commit(stroke: Stroke): void {
const asset = this.asset();
const frame = asset?.frames.find((f) => f.id === stroke.frameId);
if (!asset || !frame) return;
const file = pixelCels.put(stroke.working);
if (file === celOf(frame, stroke.layerId)) {
this.redraw();
return;
}
const change = { frameId: stroke.frameId, layerId: stroke.layerId, file };
this.state.execute(
new EditPixelArtCommand(asset.id, PIXEL_TOOL_LABELS[stroke.tool], (s) => withCels(s, [change])),
);
}
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1);
if (zoom !== this.view.zoom) {
// The pixel under the pointer stays under it.
const x = (local.x - this.view.panX) / this.view.zoom;
const y = (local.y - this.view.panY) / this.view.zoom;
this.view = { panX: Math.round(local.x - x * zoom), panY: Math.round(local.y - y * zoom), zoom };
this.needsFit = false;
this.redraw();
this.onChange();
}
e.preventDefault();
}
/** A frame composed into a picture of its own; `slot` keeps the current frame and its neighbours apart. */
private framePicture(
slot: string,
asset: PixelArtAsset,
frame: PixelFrame,
read: CelReader,
): Texture | null {
const rgba = composeFrame(asset, paletteColors(this.state.project, asset), frame, read);
if (!rgba) return null;
let picture = this.pictures.get(slot);
if (!picture || picture.canvas.width !== asset.width || picture.canvas.height !== asset.height) {
picture?.texture.destroy(true);
const canvas = new OffscreenCanvas(asset.width, asset.height);
picture = {
canvas,
texture: new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) }),
};
this.pictures.set(slot, picture);
}
picture.canvas.getContext('2d')?.putImageData(new ImageData(rgba, asset.width, asset.height), 0, 0);
picture.texture.source.update();
return picture.texture;
}
override redraw(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.checker.clear();
this.grid.clear();
this.overlay.clear();
this.onion.removeChildren().forEach((child) => child.destroy());
this.tiles.removeChildren().forEach((child) => child.destroy());
this.art.visible = false;
const asset = this.asset();
const frame = asset ? this.frame(asset) : undefined;
if (!asset || !frame) {
this.setHint(asset ? '' : 'Open a pixel art asset to paint on it.');
return;
}
if (this.needsFit && this.w > 0 && this.h > 0) {
const zoom = fitZoom(asset.width, asset.height, this.w, this.h);
this.view = centredView(asset.width, asset.height, this.w, this.h, zoom);
this.needsFit = false;
// The readout names the zoom; the fit happens once the canvas has its size, after the tab told it.
queueMicrotask(() => this.onChange());
}
const { panX, panY, zoom } = this.view;
const w = asset.width * zoom;
const h = asset.height * zoom;
drawChecker(this.checker, panX, panY, w, h, this.w, this.h);
const stroke = this.stroke;
const shown: PixelFrame = stroke
? { id: frame.id, cels: { ...frame.cels, [stroke.layerId]: WORKING } }
: frame;
const read: CelReader = (file) => (file === WORKING ? (stroke?.working ?? null) : pixelCels.read(file));
const texture = this.framePicture('frame', asset, shown, read);
if (!texture) {
this.setHint('Loading pixels…');
return;
}
this.setHint('');
this.putSprite(this.art, texture, panX, panY, zoom);
this.art.visible = true;
if (this.showOnion) this.drawOnion(asset, frame);
if (this.showTiles)
for (let ty = -1; ty <= 1; ty++)
for (let tx = -1; tx <= 1; tx++) {
if (tx === 0 && ty === 0) continue;
const tile = new Sprite(texture);
this.putSprite(tile, texture, panX + tx * w, panY + ty * h, zoom);
this.tiles.addChild(tile);
}
if (zoom >= GRID_FROM_ZOOM) this.drawGrid(asset, panX, panY, zoom);
this.overlay.rect(panX - 0.5, panY - 0.5, w + 1, h + 1).stroke({ width: 1, color: Theme.colors.border });
const hover = this.hover;
if (hover && !stroke && this.tool !== 'fill' && this.tool !== 'picker') {
const n = this.brush;
const x0 = hover.x - Math.floor((n - 1) / 2);
const y0 = hover.y - Math.floor((n - 1) / 2);
this.overlay
.rect(panX + x0 * zoom, panY + y0 * zoom, n * zoom, n * zoom)
.stroke({ width: 1, color: Theme.colors.accent });
}
}
private putSprite(sprite: Sprite, texture: Texture, x: number, y: number, zoom: number): void {
sprite.texture = texture;
sprite.position.set(x, y);
sprite.scale.set(zoom);
}
/** The frames before and after the one being painted, faintly, under it (answer 5). */
private drawOnion(asset: PixelArtAsset, frame: PixelFrame): void {
const index = asset.frames.indexOf(frame);
for (const [slot, neighbour] of [
['before', asset.frames[index - 1]],
['after', asset.frames[index + 1]],
] as const) {
if (!neighbour) continue;
const texture = this.framePicture(slot, asset, neighbour, (file) => pixelCels.read(file));
if (!texture) continue;
const sprite = new Sprite(texture);
this.putSprite(sprite, texture, this.view.panX, this.view.panY, this.view.zoom);
sprite.alpha = ONION_ALPHA;
this.onion.addChild(sprite);
}
}
/** One line between every two pixels, only where the widget shows them. */
private drawGrid(asset: PixelArtAsset, panX: number, panY: number, zoom: number): void {
const x0 = Math.max(1, Math.ceil(-panX / zoom));
const x1 = Math.min(asset.width - 1, Math.floor((this.w - panX) / zoom));
const y0 = Math.max(1, Math.ceil(-panY / zoom));
const y1 = Math.min(asset.height - 1, Math.floor((this.h - panY) / zoom));
const top = Math.max(0, panY);
const bottom = Math.min(this.h, panY + asset.height * zoom);
const left = Math.max(0, panX);
const right = Math.min(this.w, panX + asset.width * zoom);
for (let x = x0; x <= x1; x++)
this.grid.moveTo(panX + x * zoom + 0.5, top).lineTo(panX + x * zoom + 0.5, bottom);
for (let y = y0; y <= y1; y++)
this.grid.moveTo(left, panY + y * zoom + 0.5).lineTo(right, panY + y * zoom + 0.5);
this.grid.stroke({ width: 1, color: 0x000000, alpha: 0.25 });
}
private setHint(text: string): void {
this.hint.text = text;
this.hint.position.set(Theme.padding, this.h - this.hint.height - Theme.padding);
}
}
+84
View File
@@ -0,0 +1,84 @@
import type { Asset, PixelArtAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { Button, type Splitter, type Widget } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import { PreviewSplit } from '../PreviewSplit';
import type { EditorState } from '../state';
import { PixelArtCanvas } from './PixelArtCanvas';
import { PixelArtSide } from './PixelArtSide';
import { resizeCanvas } from './pixelActions';
import { openResizeDialog, ResizeCanvasDialog } from './ResizeCanvasDialog';
/** Room kept for the "32 × 32 px · 4 frames · 8x" readout. */
const INFO_WIDTH = 190;
/**
* The Pixel art tab (#130): the canvas that paints, beside it the panel with tools, colours, layers,
* frames and the playing preview. The asset's palette and filter are rows of the inspector.
*/
export class PixelArtEditor extends AssetTabEditor<PixelArtAsset, PixelArtCanvas> {
readonly resizeButton = new Button('Resize…', { onClick: () => this.openResize() });
/** Assigned by `body`; public for the check scripts, which press everything on it. */
side!: PixelArtSide;
splitter!: Splitter;
private started = performance.now();
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
const canvas = new PixelArtCanvas(state);
this.mount(canvas, [this.resizeButton]);
// The colour, the frame, the layer and the zoom live on the canvas; the panel and the readout show them.
canvas.onChange = () => {
this.side.refresh();
this.refresh();
};
}
protected override body(canvas: PixelArtCanvas): Widget {
this.side = new PixelArtSide(this.state, canvas);
const split = new PreviewSplit(canvas, this.side);
this.splitter = split.splitter;
return split;
}
/** Called every frame while the tab is showing: the preview is a moving picture. */
tick(): void {
this.side.preview.tick((performance.now() - this.started) / 1000);
}
private openResize(): void {
const asset = this.asset();
if (!asset) return;
openResizeDialog(
new ResizeCanvasDialog(asset.width, asset.height, (width, height, anchor) => {
const current = findAsset(this.state.project, asset.id);
if (current?.type === 'pixelart') resizeCanvas(this.state, current, width, height, anchor);
}),
);
}
override showAsset(assetId: string): void {
super.showAsset(assetId);
this.side.refresh();
}
override selectedId(): string {
const s = this.state.selection;
if (s?.kind !== 'asset') return '';
return findAsset(this.state.project, s.assetId)?.type === 'pixelart' ? s.assetId : '';
}
override isMine(asset: Asset): asset is PixelArtAsset {
return asset.type === 'pixelart';
}
protected override infoText(asset: PixelArtAsset): string {
const count = asset.frames.length;
return `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'} · ${this.canvas.zoom}x`;
}
protected override syncExtras(asset: PixelArtAsset | null): void {
this.fitButton.setEnabled(asset !== null);
this.resizeButton.setEnabled(asset !== null);
}
}
+370
View File
@@ -0,0 +1,370 @@
/**
* The panel beside the pixel art canvas (#130): tools, colours, layers, frames, view and preview in
* one column, so everything the canvas paints with is next to it. The rows the inspector shows for
* the asset – palette and filter – stay in the inspector, as with every other asset.
*/
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { nextAssetName } from '../../core/commands/naming';
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
import { colorAt, frameLabel, PALETTE_KEY, paletteAssetOf, paletteColors } from '../../core/model/pixelart';
import { BRUSH_MAX, BRUSH_MIN } from '../../core/model/pixelDraw';
import {
Button,
Checkbox,
ColorField,
Dropdown,
HStack,
Label,
ListView,
Menu,
NumberField,
ScrollContainer,
Spacer,
TextField,
Theme,
VStack,
type Widget,
} from '../../ui';
import type { EditorState } from '../state';
import { PaletteGrid } from './PaletteGrid';
import { type PixelArtCanvas, PIXEL_TOOL_LABELS, PIXEL_TOOLS, type PixelTool } from './PixelArtCanvas';
import {
addFrame,
addLayer,
addPaletteColor,
mergeLayerDown,
moveFrame,
moveLayer,
removeFrame,
removeLastPaletteColor,
removeLayer,
renameLayer,
setLayerHidden,
setLayerOpacity,
setPaletteColor,
} from './pixelActions';
import { PixelPreview } from './PixelPreview';
/** Four tools to a row, so the column stays narrow enough for the canvas beside it. */
const TOOLS_PER_ROW = 4;
const LIST_ROWS = 4;
function brushLabel(size: number): string {
return `Brush: ${size} px`;
}
export class PixelArtSide extends ScrollContainer {
private readonly column = new VStack({ gap: Theme.gap, padding: Theme.padding });
/** Public for the check scripts: every control they press. */
readonly toolButtons = new Map<PixelTool, Button>();
readonly brushButton = new Dropdown(brushLabel(1), { width: 120 });
readonly filledBox = new Checkbox(false);
readonly contiguousBox = new Checkbox(true);
readonly palette = new PaletteGrid();
readonly colorField = new ColorField(0);
readonly addColorButton = new Button('+ Colour');
readonly removeColorButton = new Button('Remove last');
readonly createPaletteButton = new Button('Create palette');
private readonly paletteNote = new Label('', { style: 'muted' });
readonly layers = new ListView();
readonly layerName = new TextField('');
readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 });
readonly layerButtons: Record<'add' | 'remove' | 'up' | 'down' | 'merge' | 'hide', Button>;
readonly frames = new ListView();
readonly frameButtons: Record<'add' | 'duplicate' | 'remove' | 'earlier' | 'later', Button>;
readonly onionBox = new Checkbox(false);
readonly tilesBox = new Checkbox(false);
readonly preview: PixelPreview;
readonly fpsField = new NumberField(8, { min: 1, max: 60, step: 1, decimals: 0 });
constructor(
private readonly state: EditorState,
private readonly canvas: PixelArtCanvas,
) {
super();
this.preview = new PixelPreview(state);
const c = this.column;
c.add(heading('Tools'));
for (let i = 0; i < PIXEL_TOOLS.length; i += TOOLS_PER_ROW) {
const tools = new HStack({ gap: Theme.gap });
for (const tool of PIXEL_TOOLS.slice(i, i + TOOLS_PER_ROW)) {
const button = new Button(PIXEL_TOOL_LABELS[tool], {
toggle: true,
onClick: () => this.setTool(tool),
});
this.toolButtons.set(tool, button);
tools.add(button, 1);
}
c.add(tools);
}
this.brushButton.onClick = () => this.openBrushMenu();
c.add(row(this.brushButton));
this.filledBox.onChange = (on) => (this.canvas.filled = on);
this.contiguousBox.onChange = (on) => (this.canvas.contiguous = on);
c.add(
row(
this.filledBox,
new Label('Filled shapes'),
new Spacer(),
this.contiguousBox,
new Label('Contiguous'),
),
);
c.add(heading('Colours'));
this.palette.onPick = (index) => this.canvas.setColor(index);
c.add(this.palette);
this.colorField.onInput = (color) => this.previewColor(color);
this.colorField.onChange = (color) => this.commitColor(color);
c.add(row(this.colorField, this.addColorButton, this.removeColorButton));
this.addColorButton.onClick = () => this.addColor();
this.removeColorButton.onClick = () => this.removeColor();
this.createPaletteButton.onClick = () => this.createPalette();
c.add(row(this.paletteNote, new Spacer(), this.createPaletteButton));
c.add(heading('Layers'));
this.layers.minHeight = LIST_ROWS * this.layers.rowHeight;
this.layers.onSelect = (id) => id && this.canvas.selectLayer(id);
c.add(this.layers);
this.layerName.onChange = (name) =>
this.withAsset((asset) => renameLayer(state, asset, this.canvas.layerId, name));
this.layerOpacity.onNumberChange = (value) =>
this.withAsset((asset) => setLayerOpacity(state, asset, this.canvas.layerId, value));
c.add(row(new Label('Name', { style: 'muted' }), this.layerName));
c.add(row(new Label('Opacity', { style: 'muted' }), this.layerOpacity));
this.layerButtons = {
add: new Button('Add', { onClick: () => this.addLayer() }),
remove: new Button('Delete', {
onClick: () => this.withAsset((a) => removeLayer(state, a, this.canvas.layerId)),
}),
up: new Button('Up', {
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, 1)),
}),
down: new Button('Down', {
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, -1)),
}),
merge: new Button('Merge down', {
onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)),
}),
hide: new Button('Hide', { minWidth: 56, onClick: () => this.toggleHidden() }),
};
const lb = this.layerButtons;
c.add(row(lb.add, lb.remove, lb.up, lb.down));
c.add(row(lb.merge, lb.hide));
c.add(heading('Frames'));
this.frames.minHeight = LIST_ROWS * this.frames.rowHeight;
this.frames.onSelect = (id) => id && this.canvas.selectFrame(id);
c.add(this.frames);
this.frameButtons = {
add: new Button('Add', { onClick: () => this.addFrame(false) }),
duplicate: new Button('Duplicate', { onClick: () => this.addFrame(true) }),
remove: new Button('Delete', {
onClick: () => this.withAsset((a) => removeFrame(state, a, this.canvas.frameId)),
}),
earlier: new Button('Earlier', {
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, -1)),
}),
later: new Button('Later', {
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, 1)),
}),
};
const fb = this.frameButtons;
c.add(row(fb.add, fb.duplicate, fb.remove));
c.add(row(fb.earlier, fb.later));
c.add(heading('View'));
this.onionBox.onChange = (on) => {
this.canvas.showOnion = on;
this.canvas.redraw();
};
this.tilesBox.onChange = (on) => {
this.canvas.showTiles = on;
this.canvas.redraw();
};
c.add(
row(this.onionBox, new Label('Onion skin'), new Spacer(), this.tilesBox, new Label('Tile preview')),
);
c.add(heading('Preview'));
c.add(this.preview);
this.fpsField.onNumberChange = (fps) => (this.preview.fps = fps);
this.fpsField.minWidth = 56;
c.add(row(new Label('Frames per second', { style: 'muted' }), new Spacer(), this.fpsField));
this.setContent(this.column);
this.setTool('pencil');
state.on('project', () => this.refresh());
}
/** Scrolls the column until `widget` is in view – the check scripts reveal a control before they click it. */
reveal(widget: Widget): void {
const top = widget.getGlobalPosition().y - this.column.getGlobalPosition().y;
this.ensureVisible(top, widget.h);
}
setTool(tool: PixelTool): void {
this.canvas.setTool(tool);
for (const [t, button] of this.toolButtons) button.setToggled(t === tool);
}
setBrush(size: number): void {
this.canvas.brush = size;
this.brushButton.setText(brushLabel(size));
}
private openBrushMenu(): void {
const items = [];
for (let size = BRUSH_MIN; size <= BRUSH_MAX; size++)
items.push({
label: `${size} px`,
checked: () => this.canvas.brush === size,
onSelect: () => this.setBrush(size),
});
new Menu(items).openBelow(this.brushButton);
}
private withAsset(run: (asset: PixelArtAsset) => unknown): void {
const asset = this.canvas.asset();
if (asset) run(asset);
}
private paletteAsset(): PaletteAsset | null {
const asset = this.canvas.asset();
return asset ? paletteAssetOf(this.state.project, asset) : null;
}
/** The colour under the picker while it is dragged, straight into the palette, as the inspector previews. */
private previewColor(color: number): void {
const palette = this.paletteAsset();
const index = this.canvas.color;
if (!palette || index < 1 || index > palette.colors.length) return;
this.colorBefore ??= palette.colors[index - 1]!;
palette.colors[index - 1] = color;
this.state.preview();
}
private commitColor(color: number): void {
const palette = this.paletteAsset();
const index = this.canvas.color;
if (!palette || index < 1 || index > palette.colors.length) return;
const before = this.colorBefore ?? palette.colors[index - 1]!;
palette.colors[index - 1] = before;
setPaletteColor(this.state, palette, index, color);
this.colorBefore = null;
}
/** The colour the picked swatch had before a preview changed it – what the commit puts back first. */
private colorBefore: number | null = null;
private addColor(): void {
const palette = this.paletteAsset();
const asset = this.canvas.asset();
if (!palette || !asset) return;
const color = colorAt(palette.colors, this.canvas.color) ?? 0xffffff;
const index = addPaletteColor(this.state, palette, color);
if (index > 0) this.canvas.setColor(index);
}
private removeColor(): void {
const palette = this.paletteAsset();
if (!palette || !removeLastPaletteColor(this.state, palette)) return;
if (this.canvas.color > palette.colors.length) this.canvas.setColor(palette.colors.length);
}
/** A palette of the default colours, named on the asset – one undo step for both. */
private createPalette(): void {
const asset = this.canvas.asset();
if (!asset) return;
const create = new CreateAssetCommand('palette', nextAssetName(this.state.project, 'Palette'));
const assign = new SetAssetPropertyCommand(asset.id, PALETTE_KEY, create.asset.id);
this.state.execute(oneStep(create.label, [create, assign]));
}
private addLayer(): void {
const asset = this.canvas.asset();
if (!asset) return;
const id = addLayer(this.state, asset, this.canvas.layerId);
if (id) this.canvas.selectLayer(id);
}
private toggleHidden(): void {
const asset = this.canvas.asset();
const layer = asset?.layers.find((l) => l.id === this.canvas.layerId);
if (asset && layer) setLayerHidden(this.state, asset, layer.id, !layer.hidden);
}
private addFrame(copy: boolean): void {
const asset = this.canvas.asset();
if (!asset) return;
const id = addFrame(this.state, asset, this.canvas.frameId, copy);
if (id) this.canvas.selectFrame(id);
}
/** Lists, fields and switches as the asset and the canvas have them now. */
refresh(): void {
const asset = this.canvas.asset();
this.preview.show(asset?.id ?? '');
const palette = asset ? paletteAssetOf(this.state.project, asset) : null;
const colors = asset ? paletteColors(this.state.project, asset) : [];
this.palette.setColors(colors, this.canvas.color);
const current = colorAt(colors, this.canvas.color);
if (current !== null) this.colorField.setValue(current);
const editable = palette !== null && current !== null;
this.colorField.setEnabled(editable);
this.addColorButton.setEnabled(palette !== null);
this.removeColorButton.setEnabled(palette !== null && palette.colors.length > 0);
this.paletteNote.setText(
palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours',
);
this.createPaletteButton.visible = asset !== null && palette === null;
const layerId = this.canvas.layerId;
this.layers.setItems(
[...(asset?.layers ?? [])].reverse().map((layer) => ({
id: layer.id,
label: layer.hidden ? `${layer.name} (hidden)` : layer.name,
hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined,
})),
);
this.layers.select(layerId || null, true);
const layer = asset?.layers.find((l) => l.id === layerId);
this.layerName.setValue(layer?.name ?? '');
this.layerOpacity.setNumber(layer?.opacity ?? 1);
const index = asset && layer ? asset.layers.indexOf(layer) : -1;
const lb = this.layerButtons;
lb.add.setEnabled(asset !== null);
lb.remove.setEnabled((asset?.layers.length ?? 0) > 1);
lb.up.setEnabled(asset !== null && index >= 0 && index < asset.layers.length - 1);
lb.down.setEnabled(index > 0);
lb.merge.setEnabled(index > 0);
lb.hide.setText(layer?.hidden ? 'Show' : 'Hide');
lb.hide.setEnabled(layer !== undefined);
const frameId = this.canvas.frameId;
this.frames.setItems((asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })));
this.frames.select(frameId || null, true);
const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1;
const fb = this.frameButtons;
fb.add.setEnabled(asset !== null);
fb.duplicate.setEnabled(frameIndex >= 0);
fb.remove.setEnabled((asset?.frames.length ?? 0) > 1);
fb.earlier.setEnabled(frameIndex > 0);
fb.later.setEnabled(asset !== null && frameIndex >= 0 && frameIndex < asset.frames.length - 1);
this.relayoutContent();
}
}
function heading(text: string): Label {
return new Label(text.toUpperCase(), { style: 'muted' });
}
function row(...widgets: Widget[]): HStack {
const stack = new HStack({ gap: Theme.gap, align: 'center' });
stack.minHeight = Theme.controlHeight;
for (const widget of widgets)
stack.add(widget, widget instanceof Spacer || widget instanceof TextField ? 1 : 0);
return stack;
}
+74
View File
@@ -0,0 +1,74 @@
import { Graphics, Sprite } from 'pixi.js';
import { findAsset } from '../../core/model/Project';
import { resolvePicture } from '../../core/model/texture';
import { type Size, Widget } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { drawChecker } from '../texture/checker';
const HEIGHT = 96;
export const PREVIEW_FPS_DEFAULT = 8;
/**
* The frames of a pixel art asset playing in a loop (answer 5), small, as the picture path draws them
* – so what plays here is what a sprite wearing the frames shows. How fast is a setting of the
* preview only: in a game the Animation asset keys the frames and says how long each one lasts.
*/
export class PixelPreview extends Widget {
private readonly checker = new Graphics();
private readonly sprite = new Sprite();
private assetId = '';
fps = PREVIEW_FPS_DEFAULT;
/** The frame on show, for the check scripts. */
shownIndex = 0;
constructor(private readonly state: EditorState) {
super();
this.addChild(this.checker, this.sprite);
this.minHeight = HEIGHT;
}
show(assetId: string): void {
this.assetId = assetId;
this.invalidate();
}
override measure(): Size {
return { w: this.minWidth, h: HEIGHT };
}
/** Called every frame while the tab shows: `seconds` is the clock the preview runs on. */
tick(seconds: number): void {
const asset = findAsset(this.state.project, this.assetId);
const count = asset?.type === 'pixelart' ? asset.frames.length : 0;
const index = count > 0 ? Math.floor(seconds * Math.max(0.1, this.fps)) % count : 0;
if (index !== this.shownIndex) {
this.shownIndex = index;
this.invalidate();
}
}
override redraw(): void {
this.checker.clear();
this.sprite.visible = false;
const asset = findAsset(this.state.project, this.assetId);
if (asset?.type !== 'pixelart' || this.w <= 0) return;
const frame = asset.frames[Math.min(this.shownIndex, asset.frames.length - 1)];
const ref = frame && resolvePicture(this.state.project, asset.frames.length > 1 ? frame.id : asset.id);
const texture = ref ? textureLibrary.get(ref) : null;
// Whole steps while the picture fits, so its pixels stay square; a big canvas is shrunk to fit.
const fits = Math.min(this.w / asset.width, HEIGHT / asset.height);
const scale = fits >= 1 ? Math.floor(fits) : fits;
const w = asset.width * scale;
const h = asset.height * scale;
const x = Math.round((this.w - w) / 2);
const y = Math.round((HEIGHT - h) / 2);
drawChecker(this.checker, x, y, w, h, this.w, HEIGHT);
if (!texture) return;
this.sprite.texture = texture;
this.sprite.position.set(x, y);
this.sprite.width = w;
this.sprite.height = h;
this.sprite.visible = true;
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { ResizeAnchor } from '../../core/model/pixelDraw';
import { PIXEL_CANVAS_MAX } from '../../core/model/pixelCel';
import { Button, HStack, NumberField, VStack } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog';
const FIELD_WIDTH = 76;
const ANCHOR_SIZE = 24;
const STEPS = [0, 0.5, 1] as const;
const slot = dialogSlot<ResizeCanvasDialog>();
/** The open resize dialog, for the check scripts. */
export const currentResizeDialog = slot.current;
export const openResizeDialog = slot.open;
/**
* Asks for the new canvas size and where the old picture stays in it (answer 3): the nine anchor
* buttons are the corners, edges and centre. What sticks out on the far side is cut off.
*/
export class ResizeCanvasDialog extends FormDialog {
private newWidth: number;
private newHeight: number;
private anchor: ResizeAnchor = { x: 0, y: 0 };
private readonly anchors: { anchor: ResizeAnchor; button: Button }[] = [];
readonly widthField: NumberField;
readonly heightField: NumberField;
/** Public for the check scripts. */
readonly confirm: Button;
constructor(
width: number,
height: number,
onConfirm: (w: number, h: number, anchor: ResizeAnchor) => void,
) {
super(300, 'Resize canvas');
this.newWidth = width;
this.newHeight = height;
const field = (value: number, set: (value: number) => void) => {
const number = new NumberField(value, {
min: 1,
max: PIXEL_CANVAS_MAX,
step: 1,
decimals: 0,
onInput: set,
onChange: set,
});
number.minWidth = FIELD_WIDTH;
return number;
};
this.widthField = field(width, (value) => (this.newWidth = Math.round(value)));
this.heightField = field(height, (value) => (this.newHeight = Math.round(value)));
this.content.add(this.formRow(this.fieldLabel('Width'), this.widthField));
this.content.add(this.formRow(this.fieldLabel('Height'), this.heightField));
const grid = new VStack({ gap: 2 });
for (const y of STEPS) {
const row = new HStack({ gap: 2 });
for (const x of STEPS) {
const anchor: ResizeAnchor = { x, y };
const button = new Button('', {
width: ANCHOR_SIZE,
toggle: true,
onClick: () => this.setAnchor(anchor),
});
this.anchors.push({ anchor, button });
row.add(button);
}
grid.add(row);
}
this.content.add(this.formRow(this.fieldLabel('Anchor'), grid));
this.setAnchor(this.anchor);
this.confirm = this.addButtons('Resize', 'primary', () =>
onConfirm(this.newWidth, this.newHeight, this.anchor),
);
}
setAnchor(anchor: ResizeAnchor): void {
this.anchor = anchor;
for (const { anchor: a, button } of this.anchors) button.setToggled(a.x === anchor.x && a.y === anchor.y);
}
/** What the dialog would resize to – the check scripts read this. */
debugInfo(): { width: number; height: number; anchor: ResizeAnchor } {
return { width: this.newWidth, height: this.newHeight, anchor: { ...this.anchor } };
}
/** Where the anchor button for (x, y) sits on screen – the check scripts click it. */
anchorButton(x: 0 | 0.5 | 1, y: 0 | 0.5 | 1): { x: number; y: number } | null {
const found = this.anchors.find((a) => a.anchor.x === x && a.anchor.y === y);
if (!found) return null;
const p = found.button.getGlobalPosition();
return { x: p.x + found.button.w / 2, y: p.y + found.button.h / 2 };
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { Project } from '../../core/model/Project';
import type { LoadedProject, ProjectStore, ProjectSummary } from '../../core/store/ProjectStore';
import type { CelUploader, PixelCels } from '../../viewport/PixelCels';
/**
* A project store that uploads the painted cels a project names before it saves the project (#130,
* answer 1). The order is the point: a saved project must never name a cel the server lacks, or
* the next load draws a hole. A failed upload fails the save, and autosave reports it like any other.
*/
export class CelUploadStore implements ProjectStore {
constructor(
private readonly inner: ProjectStore,
private readonly cels: PixelCels,
private readonly uploader: CelUploader,
) {}
list(): Promise<ProjectSummary[]> {
return this.inner.list();
}
load(id: string, prepare?: (raw: unknown) => Promise<void>): Promise<LoadedProject> {
return this.inner.load(id, prepare);
}
async save(project: Project, revision: number): Promise<number> {
await this.cels.upload(project, this.uploader);
return this.inner.save(project, revision);
}
create(name: string): Promise<LoadedProject> {
return this.inner.create(name);
}
delete(id: string): Promise<void> {
return this.inner.delete(id);
}
}
+187
View File
@@ -0,0 +1,187 @@
/**
* What the side panel of the pixel art tab does to an asset (#130). The layer and frame lists are
* pure state edits; resizing and merging also need the pixels, which only this browser holds, so
* their new cels are worked out here and handed to the edit by name.
*/
import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../core/commands/pixelArtCommands';
import { newId } from '../../core/ids';
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
import { celFilesOf, celOf, PALETTE_MAX } from '../../core/model/pixelart';
import { type CelPixels, emptyCel } from '../../core/model/pixelCel';
import { mergeCels, type ResizeAnchor, resizeCel } from '../../core/model/pixelDraw';
import {
type PixelArtState,
withCanvasSize,
withFrameAdded,
withFrameMoved,
withFrameRemoved,
withLayerAdded,
withLayerChanged,
withLayerMergedDown,
withLayerMoved,
withLayerRemoved,
} from '../../core/model/pixelEdit';
import { pixelCels } from '../../viewport/PixelCels';
import type { EditorState } from '../state';
function edit(
state: EditorState,
asset: PixelArtAsset,
label: string,
run: (s: PixelArtState) => PixelArtState,
): boolean {
return state.execute(new EditPixelArtCommand(asset.id, label, run));
}
/** Every cel the asset names, read; null while any of them is still loading. */
function loadedCels(asset: PixelArtAsset): Map<string, CelPixels> | null {
const cels = new Map<string, CelPixels>();
for (const file of celFilesOf(asset)) {
const cel = pixelCels.read(file);
if (cel === undefined) return null;
cels.set(file, cel ?? emptyCel(asset.width, asset.height));
}
return cels;
}
const STILL_LOADING = 'The pixels are still loading – try again in a moment.';
/** The new layer's id, so the tab can go on painting on it. */
export function addLayer(state: EditorState, asset: PixelArtAsset, aboveId: string): string {
const id = newId();
return edit(state, asset, 'Add layer', (s) => withLayerAdded(s, id, aboveId)) ? id : '';
}
export function removeLayer(state: EditorState, asset: PixelArtAsset, layerId: string): boolean {
return edit(state, asset, 'Delete layer', (s) => withLayerRemoved(s, layerId));
}
export function renameLayer(
state: EditorState,
asset: PixelArtAsset,
layerId: string,
name: string,
): boolean {
return edit(state, asset, 'Rename layer', (s) => withLayerChanged(s, layerId, { name }));
}
export function setLayerHidden(
state: EditorState,
asset: PixelArtAsset,
layerId: string,
hidden: boolean,
): boolean {
return edit(state, asset, hidden ? 'Hide layer' : 'Show layer', (s) =>
withLayerChanged(s, layerId, { hidden }),
);
}
export function setLayerOpacity(
state: EditorState,
asset: PixelArtAsset,
layerId: string,
opacity: number,
): boolean {
return edit(state, asset, 'Layer opacity', (s) => withLayerChanged(s, layerId, { opacity }));
}
/** Up is towards the top of the drawing order, the end of the list. */
export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, step: 1 | -1): boolean {
const index = asset.layers.findIndex((layer) => layer.id === layerId);
return edit(state, asset, step > 0 ? 'Move layer up' : 'Move layer down', (s) =>
withLayerMoved(s, layerId, index + step),
);
}
/** The layer laid onto the one below it on every frame; its opacity is not kept (see `mergeCels`). */
export function mergeLayerDown(state: EditorState, asset: PixelArtAsset, layerId: string): boolean {
const index = asset.layers.findIndex((layer) => layer.id === layerId);
const lower = asset.layers[index - 1];
if (!lower) {
state.setNotice('There is no layer below to merge into.');
return false;
}
const cels = loadedCels(asset);
if (!cels) {
state.setNotice(STILL_LOADING);
return false;
}
const empty = emptyCel(asset.width, asset.height);
const merged: Record<string, string> = {};
for (const frame of asset.frames) {
const below = cels.get(celOf(frame, lower.id)) ?? empty;
const above = cels.get(celOf(frame, layerId)) ?? empty;
merged[frame.id] = pixelCels.put(mergeCels(below, above));
}
return edit(state, asset, 'Merge layer down', (s) => withLayerMergedDown(s, layerId, merged));
}
/** A new frame after `afterId`: empty, or a copy of it. Returns its id. */
export function addFrame(state: EditorState, asset: PixelArtAsset, afterId: string, copy: boolean): string {
const id = newId();
const done = edit(state, asset, copy ? 'Duplicate frame' : 'Add frame', (s) =>
withFrameAdded(s, id, afterId, copy ? afterId : ''),
);
return done ? id : '';
}
export function removeFrame(state: EditorState, asset: PixelArtAsset, frameId: string): boolean {
return edit(state, asset, 'Delete frame', (s) => withFrameRemoved(s, frameId));
}
export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, step: 1 | -1): boolean {
const index = asset.frames.findIndex((frame) => frame.id === frameId);
return edit(state, asset, step > 0 ? 'Move frame later' : 'Move frame earlier', (s) =>
withFrameMoved(s, frameId, index + step),
);
}
/** Every cel moved onto a canvas of the new size (answer 3), in one undo step. */
export function resizeCanvas(
state: EditorState,
asset: PixelArtAsset,
width: number,
height: number,
anchor: ResizeAnchor,
): boolean {
if (width === asset.width && height === asset.height) return false;
const cels = loadedCels(asset);
if (!cels) {
state.setNotice(STILL_LOADING);
return false;
}
const resized: Record<string, string> = {};
for (const [file, cel] of cels) resized[file] = pixelCels.put(resizeCel(cel, width, height, anchor));
return edit(state, asset, 'Resize canvas', (s) => withCanvasSize(s, width, height, resized));
}
export function setPaletteColor(
state: EditorState,
palette: PaletteAsset,
index: number,
color: number,
): boolean {
if (index < 1 || index > palette.colors.length || palette.colors[index - 1] === color) return false;
const colors = [...palette.colors];
colors[index - 1] = color;
return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', colors));
}
/** Appends a colour; returns its index, 0 when the palette is full. */
export function addPaletteColor(state: EditorState, palette: PaletteAsset, color: number): number {
if (palette.colors.length >= PALETTE_MAX) {
state.setNotice(`A palette holds at most ${PALETTE_MAX} colours.`);
return 0;
}
const colors = [...palette.colors, color];
return state.execute(new SetPaletteColorsCommand(palette.id, 'Add colour', colors)) ? colors.length : 0;
}
/**
* Only the last colour can go: every pixel names its colour by index, and taking one out of the
* middle would shift the colour of every pixel above it in every picture painted with the palette.
*/
export function removeLastPaletteColor(state: EditorState, palette: PaletteAsset): boolean {
if (palette.colors.length === 0) return false;
return state.execute(new SetPaletteColorsCommand(palette.id, 'Remove colour', palette.colors.slice(0, -1)));
}
+44
View File
@@ -0,0 +1,44 @@
/**
* The view rules of the pixel art tab (#130) that need no Pixi: whole zoom steps, where the grid
* starts, and which pixel a pointer is over – so they can be checked without a browser.
*/
import type { SheetView } from '../texture/sheetView';
/** The zoom steps (answer 9: whole steps from 1x to 32x). Doubling alone jumps too far in the middle. */
export const ZOOM_STEPS = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32] as const;
/** From this zoom on the pixel grid is drawn; below it the lines would be denser than the pixels. */
export const GRID_FROM_ZOOM = 8;
/** The largest step at which the canvas still fits the area, never below 1x. */
export function fitZoom(canvasW: number, canvasH: number, areaW: number, areaH: number): number {
let zoom: number = ZOOM_STEPS[0];
for (const step of ZOOM_STEPS)
if (canvasW * step <= areaW * 0.9 && canvasH * step <= areaH * 0.9) zoom = step;
return zoom;
}
/** The next step up (`direction` 1) or down (-1) from `zoom`; stays at the ends. */
export function stepZoom(zoom: number, direction: 1 | -1): number {
if (direction > 0) return ZOOM_STEPS.find((step) => step > zoom) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1]!;
return [...ZOOM_STEPS].reverse().find((step) => step < zoom) ?? ZOOM_STEPS[0];
}
/** The canvas centred in the area at a zoom, on whole screen pixels so the pixel edges stay sharp. */
export function centredView(
canvasW: number,
canvasH: number,
areaW: number,
areaH: number,
zoom: number,
): SheetView {
return {
panX: Math.round((areaW - canvasW * zoom) / 2),
panY: Math.round((areaH - canvasH * zoom) / 2),
zoom,
};
}
/** The pixel under a widget-local point – anywhere, inside the canvas or not. */
export function pixelUnder(view: SheetView, localX: number, localY: number): { x: number; y: number } {
return { x: Math.floor((localX - view.panX) / view.zoom), y: Math.floor((localY - view.panY) / view.zoom) };
}
+11 -1
View File
@@ -28,11 +28,21 @@ export abstract class FileLibrary<T> {
return () => this.listeners.delete(fn);
}
/** Runs the listeners: something they draw from has changed without a file arriving here. */
notifyChange(): void {
for (const listener of this.listeners) listener();
}
protected abstract fetchFile(url: string, file: string): Promise<T>;
/** Keeps what arrived; the listeners run right after. */
protected abstract keep(file: string, loaded: T): void;
/** A file that could not be loaded; it is not asked for again. */
protected hasFailed(file: string): boolean {
return this.failed.has(file);
}
protected startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
@@ -40,7 +50,7 @@ export abstract class FileLibrary<T> {
(loaded) => {
this.loading.delete(file);
this.keep(file, loaded);
for (const listener of this.listeners) listener();
this.notifyChange();
},
(error: unknown) => {
this.loading.delete(file);
+88
View File
@@ -0,0 +1,88 @@
import type { Project } from '../core/model/Project';
import { celFilesOf } from '../core/model/pixelart';
import { type CelPixels, celFileName, decodeCel, encodeCel, isEmptyCel } from '../core/model/pixelCel';
import { FileLibrary } from './FileLibrary';
/** Where cels go on the server: raw bytes in, the stored name back. */
export interface CelUploader {
upload(bytes: ArrayBuffer, type: string): Promise<{ file: string }>;
}
/**
* The pixels of pixel art (#130) in memory, keyed by cel file. A cel painted here is named from its
* bytes and kept at once, so the model can point at it while nothing has left the browser yet; the
* cels the server does not hold are uploaded before the project that names them is saved (answer 1).
* A cel from the server is loaded on the first ask, like a texture.
*/
export class PixelCels extends FileLibrary<CelPixels> {
private readonly cels = new Map<string, CelPixels>();
/** Painted here and not on the server yet. */
private readonly local = new Set<string>();
constructor() {
super('/game-maker/data/pixels', 'pixels');
}
/**
* The pixels of a cel: null for the empty name and for a file that cannot be read, undefined while
* it loads. A broken file reads as empty rather than holding the picture back for good.
*/
read(file: string): CelPixels | null | undefined {
if (!file) return null;
const cel = this.cels.get(file);
if (cel) return cel;
if (this.hasFailed(file)) return null;
this.startLoading(file);
return undefined;
}
/** Keeps painted pixels and returns the name they go by; an empty cel is `''`, no file at all. */
put(cel: CelPixels): string {
if (isEmptyCel(cel)) return '';
const bytes = encodeCel(cel);
const file = celFileName(bytes);
if (!this.cels.has(file)) {
this.cels.set(file, { width: cel.width, height: cel.height, data: cel.data.slice() });
this.local.add(file);
}
return file;
}
/** The cels this project names that only this browser holds. */
pending(project: Project): string[] {
const files = new Set<string>();
for (const asset of project.assets)
if (asset.type === 'pixelart')
for (const file of celFilesOf(asset)) if (this.local.has(file)) files.add(file);
return [...files];
}
/**
* Sends every cel the project needs and the server lacks. A name the server works out differently
* is an error: the project would point at a file that is not there.
*/
async upload(project: Project, store: CelUploader): Promise<void> {
for (const file of this.pending(project)) {
const cel = this.cels.get(file);
if (!cel) continue;
const bytes = encodeCel(cel);
const stored = await store.upload(bytes.buffer as ArrayBuffer, 'application/octet-stream');
if (stored.file !== file) throw new Error(`The server stored pixels as ${stored.file}, not ${file}`);
this.local.delete(file);
}
}
protected async fetchFile(url: string): Promise<CelPixels> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const cel = decodeCel(new Uint8Array(await response.arrayBuffer()));
if (!cel) throw new Error('not a cel');
return cel;
}
protected keep(file: string, cel: CelPixels): void {
this.cels.set(file, cel);
}
}
export const pixelCels = new PixelCels();
+43
View File
@@ -0,0 +1,43 @@
/**
* The drawn half of pixel art (#130): every frame of an asset composed from its layers and laid side
* by side onto one canvas, handed to the picture path as the generated picture of #185. The whole
* asset is the sheet, and a frame is a cut of it – `TextureLibrary.crop` reads the canvas back like
* an uploaded sheet, so a sprite, a tile or an animation key wearing a frame gets only that frame.
*
* Composing happens on the CPU: the pixels are indices, and what they mean is a palette lookup and
* an opacity per layer – no reason to go through the GPU for it.
*/
import { CanvasSource, Texture } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { registerPictureSource } from '../core/model/generated';
import { composeSheet, paletteColors, pixelArtPicture } from '../core/model/pixelart';
import type { Project } from '../core/model/Project';
import { registerPictureGenerator } from './generatedPictures';
import { pixelCels } from './PixelCels';
import { textureLibrary } from './TextureLibrary';
/**
* Registers pixel art in both halves of the picture path; the returned function takes it out. A cel
* arriving from the server tells whoever draws with textures – the picture waited for it.
*/
export function installPixelArtPictures(): () => void {
const removeSource = registerPictureSource('pixelart', pixelArtPicture);
const removeGenerator = registerPictureGenerator('pixelart', rasterisePixelArt);
const removeListener = pixelCels.onChange(() => textureLibrary.notifyChange());
return () => {
removeSource();
removeGenerator();
removeListener();
};
}
/** The sheet as a texture; null while a cel is loading, and nothing is remembered for a null. */
export function rasterisePixelArt(asset: Asset, project?: Project): Texture | null {
if (asset.type !== 'pixelart' || asset.frames.length === 0) return null;
const rgba = composeSheet(asset, paletteColors(project, asset), (file) => pixelCels.read(file));
if (!rgba) return null;
const width = asset.width * asset.frames.length;
const canvas = new OffscreenCanvas(width, asset.height);
canvas.getContext('2d')?.putImageData(new ImageData(rgba, width, asset.height), 0, 0);
return new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) });
}