diff --git a/.gitignore b/.gitignore index 1b5fc228..3a05c82d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /data/textures/ /data/fonts/ /data/sounds/ +/data/pixels/ /data/plugins/ /dist/ /node_modules/ diff --git a/README.md b/README.md index c1cafa1e..33b3f1cb 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1232 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1259 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index cd001e19..f5d1ed01 100644 --- a/api/index.php +++ b/api/index.php @@ -35,19 +35,22 @@ declare(strict_types=1); * POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes} * DELETE ?r=sounds/{file} → 204 * + * POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130) + * * GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name * GET ?r=plugins/{file} → the module source as text/javascript * * `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and * empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`. * - * Stored images, fonts and sounds are served straight from `data/textures/`, `data/fonts/` and - * `data/sounds/` as static files; the API only writes them. + * Stored images, fonts, sounds and cels are served straight from `data/textures/`, `data/fonts/`, + * `data/sounds/` and `data/pixels/` as static files; the API only writes them. */ require __DIR__ . '/lib/Auth.php'; require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/OriginalName.php'; +require __DIR__ . '/lib/PixelStore.php'; require __DIR__ . '/lib/PluginStore.php'; require __DIR__ . '/lib/Response.php'; require __DIR__ . '/lib/SessionStore.php'; @@ -286,6 +289,24 @@ function routeSounds(array $parts, string $method): never Response::error('Method not allowed', 405); } +/** @param list $parts */ +function routePixels(array $parts, string $method): never +{ + if (count($parts) !== 1 || $method !== 'POST') { + Response::error('Method not allowed', 405); + } + $store = new PixelStore(dirname(__DIR__) . '/data/pixels'); + $bytes = Response::rawBody(); + if (strlen($bytes) > PixelStore::MAX_BYTES) { + Response::error('A cel is at most ' . PixelStore::MAX_BYTES . ' bytes', 413); + } + try { + Response::json($store->store($bytes), 201); + } catch (InvalidPixelsException $e) { + Response::error($e->getMessage(), 422); + } +} + /** @param list $parts */ function routePlugins(array $parts, string $method): never { @@ -336,7 +357,7 @@ try { if ($parts[0] === 'auth') { routeAuth($parts, $method, $auth); } - // Images, fonts, sounds and plugins are shared between accounts; being signed in is still the + // Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the // price of admission, so an uploaded file cannot be dropped in by a stranger. $user = $auth->require(); @@ -345,6 +366,7 @@ try { 'textures' => routeTextures($parts, $method), 'fonts' => routeFonts($parts, $method), 'sounds' => routeSounds($parts, $method), + 'pixels' => routePixels($parts, $method), 'plugins' => routePlugins($parts, $method), default => Response::error('Not found', 404), }; diff --git a/api/lib/PixelStore.php b/api/lib/PixelStore.php new file mode 100644 index 00000000..6f15465e --- /dev/null +++ b/api/lib/PixelStore.php @@ -0,0 +1,73 @@ +dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { + throw new RuntimeException("Cannot create pixel directory {$this->dir}"); + } + } + + public static function isValidName(string $name): bool + { + return (bool) preg_match('/^[a-f0-9]{64}\.px$/', $name); + } + + /** + * @return array{file:string,bytes:int} + * @throws InvalidPixelsException when the bytes are not a cel + */ + public function store(string $bytes): array + { + $length = strlen($bytes); + if ($length < self::HEADER_BYTES || substr($bytes, 0, 4) !== self::MAGIC || ord($bytes[4]) !== self::VERSION) { + throw new InvalidPixelsException('Not a pixel cel'); + } + $width = ord($bytes[6]) | (ord($bytes[7]) << 8); + $height = ord($bytes[8]) | (ord($bytes[9]) << 8); + if ($width < 1 || $height < 1 || $width > self::MAX_SIDE || $height > self::MAX_SIDE) { + throw new InvalidPixelsException('A cel is 1 to ' . self::MAX_SIDE . ' pixels per side'); + } + if ($length !== self::HEADER_BYTES + $width * $height) { + throw new InvalidPixelsException('The cel does not hold ' . $width . ' x ' . $height . ' pixels'); + } + + $file = hash('sha256', $bytes) . '.px'; + $path = $this->dir . '/' . $file; + if (!is_file($path)) { + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { + if (is_file($tmp)) { + unlink($tmp); + } + throw new RuntimeException('Cannot write pixel file'); + } + chmod($path, 0664); + } + return ['file' => $file, 'bytes' => $length]; + } +} + +/** Thrown by PixelStore::store when the uploaded bytes are not a cel. */ +final class InvalidPixelsException extends RuntimeException +{ +} diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index f13e7b5b..4faf00e1 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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': diff --git a/src/core/commands/pixelArtCommands.ts b/src/core/commands/pixelArtCommands.ts new file mode 100644 index 00000000..244cbcb8 --- /dev/null +++ b/src/core/commands/pixelArtCommands.ts @@ -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]; + } +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 706931b7..ab2eab1e 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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; +} + +/** + * 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 diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 48f41412..8b4fc28d 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { 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 = new Set([ 'scene', 'texture', 'vectorart', + 'pixelart', + 'palette', 'animation', 'tile', 'statemachine', diff --git a/src/core/model/pixelCel.ts b/src/core/model/pixelCel.ts new file mode 100644 index 00000000..22c0e32d --- /dev/null +++ b/src/core/model/pixelCel.ts @@ -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/.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)); +} diff --git a/src/core/model/pixelDraw.ts b/src/core/model/pixelDraw.ts new file mode 100644 index 00000000..b585c527 --- /dev/null +++ b/src/core/model/pixelDraw.ts @@ -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; +} diff --git a/src/core/model/pixelEdit.ts b/src/core/model/pixelEdit.ts new file mode 100644 index 00000000..23df49e7 --- /dev/null +++ b/src/core/model/pixelEdit.ts @@ -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>, +): 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>, +): 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>, +): 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; +} diff --git a/src/core/model/pixelart.ts b/src/core/model/pixelart.ts new file mode 100644 index 00000000..442ff70f --- /dev/null +++ b/src/core/model/pixelart.ts @@ -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(); + 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 | 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 | 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; +} diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index cae159d6..5953c020 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -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, diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 5ebc83c2..bb3ac478 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { ], }, ], + // 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 }, diff --git a/src/core/properties/textureRef.ts b/src/core/properties/textureRef.ts index d4dc461a..9a23a211 100644 --- a/src/core/properties/textureRef.ts +++ b/src/core/properties/textureRef.ts @@ -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({ diff --git a/src/core/store/HttpFileStore.ts b/src/core/store/HttpFileStore.ts index 18eaf59f..16f02827 100644 --- a/src/core/store/HttpFileStore.ts +++ b/src/core/store/HttpFileStore.ts @@ -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, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 75340991..47552bfd 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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): 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, + takenIds: Set, +): { width: number; height: number; layers: PixelLayer[]; frames: PixelFrame[] } { + const layers: PixelLayer[] = []; + const layerIds = new Set(); + 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 = {}; + 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". diff --git a/src/editor/App.ts b/src/editor/App.ts index e2150a16..19916bde 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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 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 // 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 => { const user = await signedInUser(app, root, auth); @@ -93,7 +99,11 @@ export async function startEditor(app: Application, root: VStack): Promise app, root, state, - new HttpProjectStore(API_ENDPOINT), + new CelUploadStore( + new HttpProjectStore(API_ENDPOINT), + pixelCels, + new HttpFileStore(API_ENDPOINT, 'pixels'), + ), new HttpFileStore(API_ENDPOINT, 'textures'), new HttpFileStore(API_ENDPOINT, 'fonts'), new HttpFileStore(API_ENDPOINT, 'sounds'), diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 8db5b6d2..38c0fd01 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 = { 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) diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 5e3cdfc7..c61ed335 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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); diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts index a7292056..4cda524b 100644 --- a/src/editor/assetTabs.ts +++ b/src/editor/assetTabs.ts @@ -10,6 +10,7 @@ export const TAB_OF_ASSET: Partial> = { particles: 'particles', texture: 'texture', vectorart: 'vectorart', + pixelart: 'pixelart', animation: 'animation', tile: 'tile', container: 'container', diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index a4b76190..09198b26 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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 = { + 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, diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 9ab60d7a..3d3021b4 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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); diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index c35922fa..919f9f4a 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -19,6 +19,9 @@ export const ASSET_GROUP_LABELS: Record = { // 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', diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 489db5c7..b26e7c56 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -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 = { collider, container, font, + palette, particles, + pixelart, rigidbody, scene, shader, diff --git a/src/editor/panels/icons/palette.svg b/src/editor/panels/icons/palette.svg new file mode 100644 index 00000000..9b2a6849 --- /dev/null +++ b/src/editor/panels/icons/palette.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/editor/panels/icons/pixelart.svg b/src/editor/panels/icons/pixelart.svg new file mode 100644 index 00000000..80346978 --- /dev/null +++ b/src/editor/panels/icons/pixelart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/pixelart/PaletteGrid.ts b/src/editor/pixelart/PaletteGrid.ts new file mode 100644 index 00000000..90cc9249 --- /dev/null +++ b/src/editor/pixelart/PaletteGrid.ts @@ -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 }); + } + } +} diff --git a/src/editor/pixelart/PixelArtCanvas.ts b/src/editor/pixelart/PixelArtCanvas.ts new file mode 100644 index 00000000..e0ee65f7 --- /dev/null +++ b/src/editor/pixelart/PixelArtCanvas.ts @@ -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 = { + 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(); + 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(); + 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[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); + } +} diff --git a/src/editor/pixelart/PixelArtEditor.ts b/src/editor/pixelart/PixelArtEditor.ts new file mode 100644 index 00000000..6b64340d --- /dev/null +++ b/src/editor/pixelart/PixelArtEditor.ts @@ -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 { + 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); + } +} diff --git a/src/editor/pixelart/PixelArtSide.ts b/src/editor/pixelart/PixelArtSide.ts new file mode 100644 index 00000000..c9c8e9c7 --- /dev/null +++ b/src/editor/pixelart/PixelArtSide.ts @@ -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(); + 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; +} diff --git a/src/editor/pixelart/PixelPreview.ts b/src/editor/pixelart/PixelPreview.ts new file mode 100644 index 00000000..11b48381 --- /dev/null +++ b/src/editor/pixelart/PixelPreview.ts @@ -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; + } +} diff --git a/src/editor/pixelart/ResizeCanvasDialog.ts b/src/editor/pixelart/ResizeCanvasDialog.ts new file mode 100644 index 00000000..982796e0 --- /dev/null +++ b/src/editor/pixelart/ResizeCanvasDialog.ts @@ -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(); + +/** 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 }; + } +} diff --git a/src/editor/pixelart/celUploadStore.ts b/src/editor/pixelart/celUploadStore.ts new file mode 100644 index 00000000..1ca4a694 --- /dev/null +++ b/src/editor/pixelart/celUploadStore.ts @@ -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 { + return this.inner.list(); + } + + load(id: string, prepare?: (raw: unknown) => Promise): Promise { + return this.inner.load(id, prepare); + } + + async save(project: Project, revision: number): Promise { + await this.cels.upload(project, this.uploader); + return this.inner.save(project, revision); + } + + create(name: string): Promise { + return this.inner.create(name); + } + + delete(id: string): Promise { + return this.inner.delete(id); + } +} diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts new file mode 100644 index 00000000..89078003 --- /dev/null +++ b/src/editor/pixelart/pixelActions.ts @@ -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 | null { + const cels = new Map(); + 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 = {}; + 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 = {}; + 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))); +} diff --git a/src/editor/pixelart/pixelView.ts b/src/editor/pixelart/pixelView.ts new file mode 100644 index 00000000..06d94d8d --- /dev/null +++ b/src/editor/pixelart/pixelView.ts @@ -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) }; +} diff --git a/src/viewport/FileLibrary.ts b/src/viewport/FileLibrary.ts index 7c9fc01d..1ab60701 100644 --- a/src/viewport/FileLibrary.ts +++ b/src/viewport/FileLibrary.ts @@ -28,11 +28,21 @@ export abstract class FileLibrary { 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; /** 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 { (loaded) => { this.loading.delete(file); this.keep(file, loaded); - for (const listener of this.listeners) listener(); + this.notifyChange(); }, (error: unknown) => { this.loading.delete(file); diff --git a/src/viewport/PixelCels.ts b/src/viewport/PixelCels.ts new file mode 100644 index 00000000..52adc0c0 --- /dev/null +++ b/src/viewport/PixelCels.ts @@ -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 { + private readonly cels = new Map(); + /** Painted here and not on the server yet. */ + private readonly local = new Set(); + + 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(); + 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 { + 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 { + 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(); diff --git a/src/viewport/pixelArtPicture.ts b/src/viewport/pixelArtPicture.ts new file mode 100644 index 00000000..6d94d32b --- /dev/null +++ b/src/viewport/pixelArtPicture.ts @@ -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' }) }); +} diff --git a/tests/core/pixel-art.test.ts b/tests/core/pixel-art.test.ts new file mode 100644 index 00000000..c749daa9 --- /dev/null +++ b/tests/core/pixel-art.test.ts @@ -0,0 +1,578 @@ +/** + * Pixel art (#130): the cel format and its name, the drawing rules, how layers make a frame, the + * edits and the one command that takes them back, the project file, and the picture path. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { CommandError } from '../../src/core/commands/Command'; +import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../src/core/commands/pixelArtCommands'; +import type { PaletteAsset, PixelArtAsset } from '../../src/core/model/Asset'; +import { registerPictureSource } from '../../src/core/model/generated'; +import { + celFilesOf, + colorAt, + composeFrame, + composeSheet, + DEFAULT_PALETTE, + findPixelFrame, + PALETTE_KEY, + paletteColors, + pixelArtPicture, + pixelArtRevision, + pixelArtWornId, +} from '../../src/core/model/pixelart'; +import { + type CelPixels, + celFileName, + decodeCel, + emptyCel, + encodeCel, + isCelFile, + sha256Hex, +} from '../../src/core/model/pixelCel'; +import { + anchorOffset, + ellipsePoints, + floodFill, + linePoints, + mergeCels, + rectPoints, + resizeCel, + stamp, +} from '../../src/core/model/pixelDraw'; +import { + pixelArtState, + withCanvasSize, + withCels, + withFrameAdded, + withFrameMoved, + withFrameRemoved, + withLayerAdded, + withLayerChanged, + withLayerMergedDown, + withLayerMoved, + withLayerRemoved, +} from '../../src/core/model/pixelEdit'; +import { findAsset, type Project } from '../../src/core/model/Project'; +import { resolvePicture } from '../../src/core/model/texture'; +import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef'; +import type { ProjectStore } from '../../src/core/store/ProjectStore'; +import { CelUploadStore } from '../../src/editor/pixelart/celUploadStore'; +import { PixelCels } from '../../src/viewport/PixelCels'; +import { fitZoom, pixelUnder, stepZoom } from '../../src/editor/pixelart/pixelView'; +import { makeProject, roundTrip } from '../helpers'; + +const FILE_A = `${'a'.repeat(64)}.px`; +const FILE_B = `${'b'.repeat(64)}.px`; + +function cel(width: number, height: number, rows: number[][]): CelPixels { + return { width, height, data: Uint8Array.from(rows.flat()) }; +} + +function art(project: Project): PixelArtAsset { + const create = new CreateAssetCommand('pixelart', 'Art'); + create.execute(project); + return create.asset as PixelArtAsset; +} + +describe('cel files', () => { + it('hashes like SHA-256', () => { + expect(sha256Hex(new Uint8Array())).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + expect(sha256Hex(new TextEncoder().encode('abc'))).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + // Two blocks: the padding has to spill into a second one. + expect(sha256Hex(new TextEncoder().encode('a'.repeat(64)))).toBe( + 'ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb', + ); + }); + + it('writes a header the server checks and reads it back', () => { + const pixels = cel(3, 2, [ + [1, 0, 2], + [0, 3, 0], + ]); + const bytes = encodeCel(pixels); + expect([...bytes.slice(0, 10)]).toEqual([0x47, 0x4d, 0x50, 0x58, 1, 0, 3, 0, 2, 0]); + expect(decodeCel(bytes)).toEqual(pixels); + expect(isCelFile(celFileName(bytes))).toBe(true); + }); + + it('refuses a cel of the wrong length, version or size', () => { + const bytes = encodeCel(emptyCel(2, 2)); + expect(decodeCel(bytes.slice(0, -1))).toBeNull(); + const version = bytes.slice(); + version[4] = 2; + expect(decodeCel(version)).toBeNull(); + const huge = encodeCel(emptyCel(2, 2)); + huge[6] = 0x01; + huge[7] = 0x02; // 513 wide + expect(decodeCel(huge)).toBeNull(); + }); +}); + +describe('drawing', () => { + it('draws a line pixel by pixel, both ends included', () => { + expect(linePoints({ x: 0, y: 0 }, { x: 3, y: 1 })).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 1 }, + { x: 3, y: 1 }, + ]); + expect(linePoints({ x: 2, y: 2 }, { x: 2, y: 2 })).toEqual([{ x: 2, y: 2 }]); + }); + + it('draws the border of a rectangle, or all of it', () => { + expect(rectPoints({ x: 3, y: 3 }, { x: 0, y: 0 }, false)).toHaveLength(12); + expect(rectPoints({ x: 0, y: 0 }, { x: 3, y: 3 }, true)).toHaveLength(16); + }); + + it('draws an ellipse whose outline has no gaps and whose fill leaves the corners', () => { + const filled = ellipsePoints({ x: 0, y: 0 }, { x: 7, y: 7 }, true); + const outline = ellipsePoints({ x: 0, y: 0 }, { x: 7, y: 7 }, false); + const has = (list: { x: number; y: number }[], x: number, y: number) => + list.some((p) => p.x === x && p.y === y); + expect(has(filled, 0, 0)).toBe(false); + expect(has(filled, 3, 3)).toBe(true); + expect(has(outline, 3, 3)).toBe(false); + expect(has(outline, 0, 3)).toBe(true); + // Every outline pixel touches another one, so the ring is closed. + for (const p of outline) + expect(outline.some((q) => q !== p && Math.abs(q.x - p.x) <= 1 && Math.abs(q.y - p.y) <= 1)).toBe(true); + // Symmetric in both axes. + for (const p of filled) expect(has(filled, 7 - p.x, p.y) && has(filled, p.x, 7 - p.y)).toBe(true); + }); + + it('stamps a brush centred on the point, an even one reaching right and down', () => { + const pixels = emptyCel(6, 6); + stamp(pixels, { x: 2, y: 2 }, 2, 5); + expect(pixels.data.reduce((n, i) => n + (i === 5 ? 1 : 0), 0)).toBe(4); + expect(pixels.data[2 * 6 + 2]).toBe(5); + expect(pixels.data[3 * 6 + 3]).toBe(5); + expect(pixels.data[1 * 6 + 1]).toBe(0); + }); + + it('fills the area joined by edges, or every pixel of that colour', () => { + const rows = [ + [0, 1, 0], + [1, 0, 0], + [0, 0, 0], + ]; + const joined = cel(3, 3, rows); + floodFill(joined, { x: 2, y: 2 }, 7, true); + // The corner pixel is cut off by two edges and only touches the area diagonally. + expect([...joined.data]).toEqual([0, 1, 7, 1, 7, 7, 7, 7, 7]); + const every = cel(3, 3, rows); + floodFill(every, { x: 2, y: 2 }, 7, false); + expect([...every.data]).toEqual([7, 1, 7, 1, 7, 7, 7, 7, 7]); + }); + + it('resizes about an anchor and cuts what sticks out', () => { + const pixels = cel(2, 2, [ + [1, 2], + [3, 4], + ]); + expect([...resizeCel(pixels, 4, 2, { x: 0.5, y: 0 }).data]).toEqual([0, 1, 2, 0, 0, 3, 4, 0]); + expect([...resizeCel(pixels, 1, 1, { x: 1, y: 1 }).data]).toEqual([4]); + expect(anchorOffset(32, 40, 0.5)).toBe(4); + }); + + it('merges a layer down: what the upper one paints covers the lower one', () => { + const merged = mergeCels(cel(2, 1, [[1, 1]]), cel(2, 1, [[0, 2]])); + expect([...merged.data]).toEqual([1, 2]); + }); +}); + +describe('composing', () => { + const colors = [0xff0000, 0x0000ff]; + + it('lays the layers over each other in list order, the last on top', () => { + const { project } = makeProject(); + const asset = art(project); + asset.width = 2; + asset.height = 1; + asset.layers = [ + { id: 'low', name: 'Low', opacity: 1 }, + { id: 'high', name: 'High', opacity: 1 }, + ]; + asset.frames = [{ id: 'f', cels: { low: FILE_A, high: FILE_B } }]; + const read = (file: string) => + file === FILE_A ? cel(2, 1, [[1, 1]]) : file === FILE_B ? cel(2, 1, [[0, 2]]) : null; + const rgba = composeFrame(asset, colors, asset.frames[0]!, read); + expect([...rgba!]).toEqual([255, 0, 0, 255, 0, 0, 255, 255]); + }); + + it('mixes a layer by its opacity and leaves a hidden one out', () => { + const { project } = makeProject(); + const asset = art(project); + asset.width = 1; + asset.height = 1; + asset.layers = [ + { id: 'low', name: 'Low', opacity: 1 }, + { id: 'high', name: 'High', opacity: 0.5 }, + ]; + asset.frames = [{ id: 'f', cels: { low: FILE_A, high: FILE_B } }]; + const read = (file: string) => (file === FILE_A ? cel(1, 1, [[1]]) : cel(1, 1, [[2]])); + expect([...composeFrame(asset, colors, asset.frames[0]!, read)!]).toEqual([128, 0, 128, 255]); + asset.layers[1]!.hidden = true; + expect([...composeFrame(asset, colors, asset.frames[0]!, read)!]).toEqual([255, 0, 0, 255]); + }); + + it('is transparent where nothing paints, and waits while a cel is loading', () => { + const { project } = makeProject(); + const asset = art(project); + asset.width = 1; + asset.height = 1; + const layer = asset.layers[0]!.id; + asset.frames[0]!.cels[layer] = FILE_A; + expect([...composeFrame(asset, colors, asset.frames[0]!, () => cel(1, 1, [[0]]))!]).toEqual([0, 0, 0, 0]); + // An index past the palette's end is transparent too. + expect([...composeFrame(asset, colors, asset.frames[0]!, () => cel(1, 1, [[9]]))!]).toEqual([0, 0, 0, 0]); + expect(composeFrame(asset, colors, asset.frames[0]!, () => undefined)).toBeNull(); + }); + + it('puts the frames side by side into one sheet', () => { + const { project } = makeProject(); + const asset = art(project); + asset.width = 1; + asset.height = 1; + const layer = asset.layers[0]!.id; + asset.frames = [ + { id: 'f1', cels: { [layer]: FILE_A } }, + { id: 'f2', cels: { [layer]: FILE_B } }, + ]; + const sheet = composeSheet(asset, colors, (file) => + file === FILE_A ? cel(1, 1, [[1]]) : cel(1, 1, [[2]]), + ); + expect([...sheet!]).toEqual([255, 0, 0, 255, 0, 0, 255, 255]); + }); + + it('reads colours by index, 0 being transparent', () => { + expect(colorAt(colors, 0)).toBeNull(); + expect(colorAt(colors, 2)).toBe(0x0000ff); + expect(colorAt(colors, 3)).toBeNull(); + }); +}); + +describe('the picture', () => { + it('changes its revision with the pixels, the palette and the layers, not with a name', () => { + const { project } = makeProject(); + const asset = art(project); + const layer = asset.layers[0]!.id; + const before = pixelArtRevision(asset, DEFAULT_PALETTE); + asset.layers[0]!.name = 'Renamed'; + expect(pixelArtRevision(asset, DEFAULT_PALETTE)).toBe(before); + asset.frames[0]!.cels[layer] = FILE_A; + const painted = pixelArtRevision(asset, DEFAULT_PALETTE); + expect(painted).not.toBe(before); + expect(pixelArtRevision(asset, [0x123456])).not.toBe(painted); + asset.layers[0]!.opacity = 0.5; + expect(pixelArtRevision(asset, DEFAULT_PALETTE)).not.toBe(painted); + }); + + it('is a sheet of all frames, and a frame id reads as a cut of it', () => { + const remove = registerPictureSource('pixelart', pixelArtPicture); + try { + const { project } = makeProject(); + const asset = art(project); + asset.width = 16; + asset.height = 8; + asset.frames.push({ id: 'second', cels: {} }); + const whole = resolvePicture(project, asset.id); + expect(whole).toMatchObject({ rect: null, sourceWidth: 32, sourceHeight: 8, smooth: false }); + expect(whole!.file.startsWith(`gen:${asset.id}@`)).toBe(true); + const second = resolvePicture(project, 'second'); + expect(second).toMatchObject({ file: whole!.file, rect: { x: 16, y: 0, w: 16, h: 8 } }); + expect(findPixelFrame(project, 'second')?.index).toBe(1); + expect(textureRefLabel(project, 'second')).toBe('Art / Frame 2'); + expect(pictureChoices(project).map((c) => c.label)).toEqual(['Art', 'Art / Frame 1', 'Art / Frame 2']); + expect(pixelArtWornId(asset)).toBe(asset.frames[0]!.id); + } finally { + remove(); + } + }); + + it('with one frame is offered as the picture alone', () => { + const remove = registerPictureSource('pixelart', pixelArtPicture); + try { + const { project } = makeProject(); + const asset = art(project); + expect(pictureChoices(project).map((c) => c.label)).toEqual(['Art']); + expect(pixelArtWornId(asset)).toBe(asset.id); + } finally { + remove(); + } + }); + + it('is painted in the palette its row names, else in the default colours', () => { + const { project } = makeProject(); + const asset = art(project); + expect(paletteColors(project, asset)).toBe(DEFAULT_PALETTE); + const palette = new CreateAssetCommand('palette', 'Palette'); + palette.execute(project); + (palette.asset as PaletteAsset).colors = [0x112233]; + asset.props[PALETTE_KEY] = palette.asset.id; + expect(paletteColors(project, asset)).toEqual([0x112233]); + }); +}); + +describe('edits', () => { + const start = () => { + const { project, stack } = makeProject(); + const asset = art(project); + return { project, stack, asset, layer: asset.layers[0]!.id, frame: asset.frames[0]!.id }; + }; + + it('sets cels and empties one with the empty name', () => { + const { asset, layer, frame } = start(); + const painted = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]); + expect(painted.frames[0]!.cels[layer]).toBe(FILE_A); + expect(withCels(painted, [{ frameId: frame, layerId: layer, file: '' }]).frames[0]!.cels).toEqual({}); + // The state it was given is left alone. + expect(asset.frames[0]!.cels).toEqual({}); + }); + + it('adds, renames, hides, moves and removes layers, and keeps the last one', () => { + const { asset, layer } = start(); + let s = withLayerAdded(pixelArtState(asset), 'second'); + expect(s.layers.map((l) => [l.id, l.name])).toEqual([ + [layer, 'Layer 1'], + ['second', 'Layer 2'], + ]); + s = withLayerChanged(s, 'second', { name: ' Ink ', hidden: true, opacity: 3 }); + expect(s.layers[1]).toEqual({ id: 'second', name: 'Ink', hidden: true, opacity: 1 }); + expect(withLayerChanged(s, 'second', { hidden: false }).layers[1]).not.toHaveProperty('hidden'); + s = withLayerMoved(s, 'second', 0); + expect(s.layers[0]!.id).toBe('second'); + s = withLayerRemoved(s, 'second'); + expect(() => withLayerRemoved(s, layer)).toThrow('at least one layer'); + expect(() => withLayerChanged(s, layer, { name: ' ' })).toThrow('needs a name'); + }); + + it('merges a layer down into the cels it is handed', () => { + const { asset, layer, frame } = start(); + let s = withLayerAdded(pixelArtState(asset), 'top'); + s = withCels(s, [{ frameId: frame, layerId: 'top', file: FILE_B }]); + s = withLayerMergedDown(s, 'top', { [frame]: FILE_A }); + expect(s.layers.map((l) => l.id)).toEqual([layer]); + expect(s.frames[0]!.cels).toEqual({ [layer]: FILE_A }); + expect(() => withLayerMergedDown(s, layer, {})).toThrow('no layer below'); + }); + + it('adds a frame empty or as a copy, moves it and keeps the last one', () => { + const { asset, layer, frame } = start(); + let s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]); + s = withFrameAdded(s, 'copy', frame, frame); + s = withFrameAdded(s, 'blank', frame); + expect(s.frames.map((f) => [f.id, f.cels[layer] ?? ''])).toEqual([ + [frame, FILE_A], + ['blank', ''], + ['copy', FILE_A], + ]); + s = withFrameMoved(s, 'copy', 0); + expect(s.frames[0]!.id).toBe('copy'); + s = withFrameRemoved(withFrameRemoved(s, 'copy'), 'blank'); + expect(() => withFrameRemoved(s, frame)).toThrow('at least one frame'); + }); + + it('resizes by swapping every cel for its counterpart', () => { + const { asset, layer, frame } = start(); + const s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]); + const resized = withCanvasSize(s, 40, 16, { [FILE_A]: FILE_B }); + expect(resized).toMatchObject({ width: 40, height: 16 }); + expect(resized.frames[0]!.cels[layer]).toBe(FILE_B); + expect(() => withCanvasSize(s, 40, 16, {})).toThrow('no counterpart'); + expect(() => withCanvasSize(s, 513, 16, { [FILE_A]: FILE_B })).toThrow('1 to 512'); + }); + + it('is one undo step per command, and a refused edit changes nothing', () => { + const { project, stack, asset, layer, frame } = start(); + stack.execute( + new EditPixelArtCommand(asset.id, 'Pencil', (s) => + withCels(s, [{ frameId: frame, layerId: layer, file: FILE_A }]), + ), + ); + stack.execute(new EditPixelArtCommand(asset.id, 'Add layer', (s) => withLayerAdded(s, 'top'))); + const current = findAsset(project, asset.id) as PixelArtAsset; + expect(current.layers).toHaveLength(2); + stack.undo(); + expect(current.layers).toHaveLength(1); + stack.undo(); + expect(current.frames[0]!.cels).toEqual({}); + stack.redo(); + expect(current.frames[0]!.cels[layer]).toBe(FILE_A); + const refused = new EditPixelArtCommand(asset.id, 'Delete layer', (s) => withLayerRemoved(s, layer)); + expect(() => refused.execute(project)).toThrow(CommandError); + expect(current.layers).toHaveLength(1); + }); + + it('sets the colours of a palette and takes them back', () => { + const { project, stack } = makeProject(); + const create = new CreateAssetCommand('palette', 'Palette'); + stack.execute(create); + const palette = create.asset as PaletteAsset; + expect(palette.colors).toEqual(DEFAULT_PALETTE); + stack.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', [0x010203])); + expect(palette.colors).toEqual([0x010203]); + stack.undo(); + expect(palette.colors).toEqual(DEFAULT_PALETTE); + expect(() => new SetPaletteColorsCommand(palette.id, 'x', [0x1000000]).execute(project)).toThrow( + CommandError, + ); + }); +}); + +describe('the project file', () => { + it('brings pixel art and palettes back as they were', () => { + const { project } = makeProject(); + const asset = art(project); + const layer = asset.layers[0]!.id; + asset.width = 48; + asset.layers.push({ id: 'ink', name: 'Ink', opacity: 0.25, hidden: true }); + asset.frames[0]!.cels = { [layer]: FILE_A, ink: FILE_B }; + asset.frames.push({ id: 'second', cels: {} }); + const palette = new CreateAssetCommand('palette', 'Palette'); + palette.execute(project); + const loaded = roundTrip(project); + expect(findAsset(loaded, asset.id)).toEqual(asset); + expect(findAsset(loaded, palette.asset.id)).toEqual(palette.asset); + }); + + it('drops what it cannot trust and keeps something to paint on', () => { + const { project } = makeProject(); + const raw = JSON.parse(JSON.stringify(project)) as { assets: unknown[] }; + raw.assets.push( + { + id: 'bad', + type: 'pixelart', + name: 'Bad', + props: {}, + width: 9999, + height: 'x', + layers: [{ id: 'l', name: 'L', opacity: 7 }, { id: 'l' }, { name: 'no id' }], + frames: [ + { id: 'f', cels: { l: FILE_A, gone: FILE_B, l2: 'not-a-file' } }, + { id: 'f' }, + { id: 'bad' }, + ], + }, + { id: 'empty', type: 'pixelart', name: 'Empty', props: {} }, + { id: 'pal', type: 'palette', name: 'Pal', props: {}, colors: [1, 'x', 0x1ffffff, 2.7] }, + ); + const loaded = roundTrip(raw as unknown as Project); + const bad = findAsset(loaded, 'bad') as PixelArtAsset; + expect(bad).toMatchObject({ width: 512, height: 32 }); + expect(bad.layers).toEqual([{ id: 'l', name: 'L', opacity: 1 }]); + // A frame may not reuse its own asset's id, nor another frame's. + expect(bad.frames).toEqual([{ id: 'f', cels: { l: FILE_A } }]); + const empty = findAsset(loaded, 'empty') as PixelArtAsset; + expect(empty.layers).toHaveLength(1); + expect(empty.frames).toHaveLength(1); + expect((findAsset(loaded, 'pal') as PaletteAsset).colors).toEqual([1, 0xffffff, 2]); + expect(celFilesOf(bad)).toEqual([FILE_A]); + }); +}); + +describe('cels in the browser', () => { + it('names painted pixels at once, keeps them, and uploads what the project names', async () => { + const cels = new PixelCels(); + const pixels = cel(2, 1, [[1, 0]]); + const file = cels.put(pixels); + expect(file).toBe(celFileName(encodeCel(pixels))); + expect(cels.put(emptyCel(2, 1))).toBe(''); + // A copy is kept: painting on on the array handed in changes nothing stored. + pixels.data[0] = 9; + expect([...cels.read(file)!.data]).toEqual([1, 0]); + + const { project } = makeProject(); + const asset = art(project); + const unnamed = cels.put(cel(1, 1, [[3]])); + asset.frames[0]!.cels[asset.layers[0]!.id] = file; + expect(cels.pending(project)).toEqual([file]); + + const sent: string[] = []; + await cels.upload(project, { + upload: (bytes) => { + const name = celFileName(new Uint8Array(bytes)); + sent.push(name); + return Promise.resolve({ file: name }); + }, + }); + expect(sent).toEqual([file]); + expect(cels.pending(project)).toEqual([]); + expect(unnamed).not.toBe(file); + }); + + it('fails the upload when the server names the bytes differently', async () => { + const cels = new PixelCels(); + const { project } = makeProject(); + const asset = art(project); + asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]])); + await expect(cels.upload(project, { upload: () => Promise.resolve({ file: FILE_A }) })).rejects.toThrow( + 'The server stored pixels as', + ); + expect(cels.pending(project)).toHaveLength(1); + }); +}); + +describe('saving', () => { + const inner = (log: string[]): ProjectStore => ({ + list: () => Promise.resolve([]), + load: () => Promise.reject(new Error('unused')), + save: () => { + log.push('save'); + return Promise.resolve(2); + }, + create: () => Promise.reject(new Error('unused')), + delete: () => Promise.resolve(), + }); + + it('uploads the painted cels before the project that names them', async () => { + const log: string[] = []; + const cels = new PixelCels(); + const { project } = makeProject(); + const asset = art(project); + asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]])); + const store = new CelUploadStore(inner(log), cels, { + upload: (bytes) => { + log.push('upload'); + return Promise.resolve({ file: celFileName(new Uint8Array(bytes)) }); + }, + }); + expect(await store.save(project, 1)).toBe(2); + expect(log).toEqual(['upload', 'save']); + }); + + it('does not save a project whose cels could not be uploaded', async () => { + const log: string[] = []; + const cels = new PixelCels(); + const { project } = makeProject(); + const asset = art(project); + asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]])); + const store = new CelUploadStore(inner(log), cels, { + upload: () => Promise.reject(new Error('offline')), + }); + await expect(store.save(project, 1)).rejects.toThrow('offline'); + expect(log).toEqual([]); + }); +}); + +describe('the view', () => { + it('zooms in whole steps and fits the largest one', () => { + expect(fitZoom(32, 32, 500, 400)).toBe(8); + expect(fitZoom(512, 512, 300, 300)).toBe(1); + expect(stepZoom(8, 1)).toBe(12); + expect(stepZoom(8, -1)).toBe(6); + expect(stepZoom(32, 1)).toBe(32); + expect(stepZoom(1, -1)).toBe(1); + }); + + it('finds the pixel under the pointer, outside the canvas too', () => { + const view = { panX: 10, panY: 20, zoom: 8 }; + expect(pixelUnder(view, 10, 20)).toEqual({ x: 0, y: 0 }); + expect(pixelUnder(view, 17.9, 27.9)).toEqual({ x: 0, y: 0 }); + expect(pixelUnder(view, 9, 20)).toEqual({ x: -1, y: 0 }); + }); +}); diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs new file mode 100644 index 00000000..42f86dce --- /dev/null +++ b/tools/e2e/48-pixelart.mjs @@ -0,0 +1,485 @@ +import { colourDistance } from '../lib/harness.mjs'; +import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; + +/** + * Pixel art (#130): a picture painted pixel by pixel, in layers and frames, with a palette. Every + * block goes the way a user goes – the asset comes out of the + menu, the tools are picked in the + * side panel and dragged across the canvas – and reads back what only a running editor can show: + * the indices in the cels, the colours on screen, and that the picture reaches a sprite and survives + * a reload. Runs last: the reload at its end starts the page afresh. + */ + +/** Colours of the default palette (PICO-8), by index. */ +const RED = 9; // #ff004d +const GREEN = 12; // #00e436 +const BLUE = 13; // #29adff +const HEX = { [RED]: '#ff004d', [GREEN]: '#00e436', [BLUE]: '#29adff' }; + +export async function pixelArt(t) { + const { + api, + check, + click, + drag, + evaluate, + json, + mutate, + navigate, + press, + projectId, + saved, + settle, + shot, + showTab, + sleep, + typeText, + undo, + } = t; + const Y = LANES.pixelArt; + + const control = async (name) => { + const found = await json(`__gm.pixelArt.control(${JSON.stringify(name)})`); + await settle(); + // Revealing may have scrolled the panel; ask again for where it is now. + return (await json(`__gm.pixelArt.control(${JSON.stringify(name)})`)) ?? found; + }; + const press_ = async (name) => { + const at = await control(name); + await click(at.x, at.y); + await settle(); + }; + const swatch = async (index) => { + await json(`__gm.pixelArt.swatch(${index})`); + await settle(); + const at = await json(`__gm.pixelArt.swatch(${index})`); + await click(at.x, at.y); + await settle(); + }; + const at = (x, y) => json(`__gm.pixelArt.pixelAt(${x}, ${y})`); + const paint = async (from, to, modifiers = {}) => { + const a = await at(from[0], from[1]); + const b = await at(to[0], to[1]); + await drag(a.x, a.y, b.x, b.y, { steps: 8, modifiers }); + await settle(); + }; + const asset = (id) => json(`__gm.pixelArt.asset(${JSON.stringify(id)})`); + /** The indices of a layer on a frame, as a flat array; all zero for an empty cel. */ + const cel = async (id, frameIndex, layerIndex) => { + const a = await asset(id); + const file = a.frames[frameIndex]?.cels[a.layers[layerIndex]?.id] ?? ''; + const read = file ? await json(`__gm.pixelArt.cel(${JSON.stringify(file)})`) : null; + return read && read !== 'loading' ? read.data : new Array(a.width * a.height).fill(0); + }; + const count = (data, index) => data.filter((i) => i === index).length; + const screen = async (x, y) => { + const p = await at(x, y); + return json(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`); + }; + + // 48a. The + menu makes the asset, and a palette with it, and opens it in its tab. + const inOverlay = async (text) => { + const box = await json('__gm.overlayRect()'); + const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; + return ( + hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null + ); + }; + const palettesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'palette').length"); + await showTab('scene'); + await t.clickText('+'); + const entry = await inOverlay('Pixel art'); + check('the + menu offers Pixel art', Boolean(entry), JSON.stringify(await json('__gm.menuRows()'))); + await click(entry.x, entry.y); + await settle(); + const id = ( + await json("__gm.state.project.assets.filter((a) => a.type === 'pixelart').map((a) => a.id)") + ).at(-1); + const made = await asset(id); + const palettes = await json( + "__gm.state.project.assets.filter((a) => a.type === 'palette').map((a) => a.id)", + ); + check( + 'it makes a 32 x 32 pixel art asset with one layer and one frame, painted in a new palette', + made?.width === 32 && + made.height === 32 && + made.layers.length === 1 && + made.frames.length === 1 && + palettes.length === palettesBefore + 1 && + made.props.palette === palettes.at(-1), + JSON.stringify({ made, palettes, palettesBefore }), + ); + check( + 'and opens it in the Pixel art tab', + (await json('__gm.pixelArt.current()')) === id && + (await json('__gm.pixelArt.info()')) === + `32 × 32 px · 1 frame · ${await json('__gm.pixelArt.zoom()')}x` && + (await json('__gm.pixelArt.zoom()')) > 1, + JSON.stringify({ + current: await json('__gm.pixelArt.current()'), + info: await json('__gm.pixelArt.info()'), + }), + ); + + // 48b. The pencil: a swatch picks the colour, a drag paints a row, and one undo takes the stroke back. + await swatch(RED); + await press_('pencil'); + await paint([2, 2], [9, 2]); + let data = await cel(id, 0, 0); + const row = data.slice(2 * 32, 3 * 32); + check( + 'a drag with the pencil paints the pixels it crosses in the picked colour', + (await json('__gm.pixelArt.color()')) === RED && + row.slice(2, 10).every((i) => i === RED) && + count(data, RED) === 8, + JSON.stringify({ color: await json('__gm.pixelArt.color()'), row, painted: count(data, RED) }), + ); + const onScreen = await screen(5, 2); + check('the canvas shows them in the palette colour', colourDistance(onScreen, HEX[RED]) < 10, onScreen); + await undo(); + data = await cel(id, 0, 0); + const undone = count(data, RED); + await t.redo(); + data = await cel(id, 0, 0); + check( + 'the stroke is one undo step', + undone === 0 && count(data, RED) === 8, + JSON.stringify({ afterUndo: undone, afterRedo: count(data, RED) }), + ); + + // 48c. Fill pours into the area it is poured into; the painted row keeps its colour. + await swatch(GREEN); + await press_('fill'); + await paint([0, 20], [0, 20]); + data = await cel(id, 0, 0); + check( + 'fill turns every transparent pixel joined to the one clicked green, and leaves the row', + count(data, GREEN) === 32 * 32 - 8 && count(data, RED) === 8, + JSON.stringify({ green: count(data, GREEN), red: count(data, RED) }), + ); + + // 48d. The picker, and Alt with any tool. + await press_('pencil'); + const redAt = await at(5, 2); + await click(redAt.x, redAt.y, 'left', { alt: true }); + await settle(); + const altPicked = await json('__gm.pixelArt.color()'); + await press_('picker'); + const greenAt = await at(20, 20); + await click(greenAt.x, greenAt.y); + await settle(); + check( + 'Alt+click picks the colour under the pointer, and so does the picker', + altPicked === RED && (await json('__gm.pixelArt.color()')) === GREEN, + JSON.stringify({ altPicked, picked: await json('__gm.pixelArt.color()') }), + ); + + // 48e. A second layer, and the shape tools on it. + const layersBefore = (await asset(id)).layers.length; + await press_('layer:add'); + let art = await asset(id); + const top = art.layers.at(-1).id; + check( + 'Add puts a new layer on top and paints on it', + art.layers.length === layersBefore + 1 && (await json('__gm.pixelArt.layerId()')) === top, + JSON.stringify({ layers: art.layers.map((l) => l.name), current: await json('__gm.pixelArt.layerId()') }), + ); + await swatch(BLUE); + await press_('rect'); + await paint([10, 10], [15, 14]); + data = await cel(id, 0, 1); + const rectCount = count(data, BLUE); + await press_('line'); + await paint([0, 31], [31, 0]); + data = await cel(id, 0, 1); + const lineCount = count(data, BLUE) - rectCount; + check( + 'a rectangle outline is its border, and a diagonal line one pixel per column', + rectCount === 2 * (6 + 5) - 4 && lineCount >= 31 && lineCount <= 32 && data[31 * 32 + 0] === BLUE, + JSON.stringify({ rectCount, lineCount }), + ); + await undo(); + const filledBox = await control('filled'); + await click(filledBox.x, filledBox.y); + await press_('ellipse'); + await paint([20, 20], [27, 27]); + data = await cel(id, 0, 1); + check( + 'a filled ellipse covers its middle and leaves the corners of its box', + data[23 * 32 + 23] === BLUE && + data[20 * 32 + 20] === 0 && + data[27 * 32 + 27] === 0 && + count(data, BLUE) - rectCount > 30, + JSON.stringify({ + middle: data[23 * 32 + 23], + corner: data[20 * 32 + 20], + ellipse: count(data, BLUE) - rectCount, + }), + ); + await click(filledBox.x, filledBox.y); + + // 48f. Hide and opacity change what the canvas shows, not the pixels. + const blueShown = await screen(10, 10); + await press_('layer:hide'); + const hidden = await screen(10, 10); + await press_('layer:hide'); + const opacity = await control('layerOpacity'); + await click(opacity.x, opacity.y); + await press('a', { ctrl: true }); + await typeText('0.5'); + await press('Enter'); + await settle(); + const half = await screen(10, 10); + art = await asset(id); + // Half blue over green, as the composer mixes straight colours. + const mixed = + '#' + + [ + [0x29, 0x00], + [0xad, 0xe4], + [0xff, 0x36], + ] + .map(([a, b]) => + Math.round(a * 0.5 + b * 0.5) + .toString(16) + .padStart(2, '0'), + ) + .join(''); + check( + 'a hidden layer is not drawn, and a layer at half opacity is mixed with the one below', + colourDistance(blueShown, HEX[BLUE]) < 10 && + colourDistance(hidden, HEX[GREEN]) < 10 && + art.layers.at(-1).opacity === 0.5 && + colourDistance(half, mixed) < 12, + JSON.stringify({ blueShown, hidden, half, mixed, opacity: art.layers.at(-1).opacity }), + ); + + // 48g. Merge down: one layer left, holding both; the upper layer's opacity is not kept. + await press_('layer:merge'); + art = await asset(id); + data = await cel(id, 0, 0); + check( + 'merging lays the upper layer onto the lower one and takes it out', + art.layers.length === 1 && data[10 * 32 + 10] === BLUE && data[2 * 32 + 5] === RED && data[0] === GREEN, + JSON.stringify({ + layers: art.layers.length, + rect: data[10 * 32 + 10], + row: data[2 * 32 + 5], + ground: data[0], + }), + ); + + // 48h. Frames: a copy, a painted difference, onion skin and the playing preview. + await press_('frame:duplicate'); + art = await asset(id); + const second = await json('__gm.pixelArt.frameId()'); + check( + 'Duplicate adds a copy of the frame after it and paints on the copy', + art.frames.length === 2 && + art.frames[1].id === second && + JSON.stringify(art.frames[1].cels) === JSON.stringify(art.frames[0].cels) && + /· 2 frames ·/.test(await json('__gm.pixelArt.info()')), + JSON.stringify({ frames: art.frames, info: await json('__gm.pixelArt.info()') }), + ); + await press_('frame:add'); + await swatch(RED); + await press_('pencil'); + await paint([16, 16], [16, 16]); + art = await asset(id); + data = await cel(id, 2, 0); + const emptyThird = count(data, 0); + const onionOff = await screen(10, 10); + const onion = await control('onion'); + await click(onion.x, onion.y); + await settle(); + const onionOn = await screen(10, 10); + await click(onion.x, onion.y); + check( + 'an empty frame shows the one before it faintly while onion skin is on', + art.frames.length === 3 && + emptyThird === 32 * 32 - 1 && + colourDistance(onionOn, onionOff) > 20 && + colourDistance(onionOn, HEX[BLUE]) > 60, + JSON.stringify({ frames: art.frames.length, emptyThird, onionOff, onionOn }), + ); + const seen = new Set(); + for (let i = 0; i < 8; i++) { + seen.add(await json('__gm.pixelArt.previewIndex()')); + await sleep(70); + } + check('the preview plays through the frames', seen.size >= 2, JSON.stringify([...seen])); + + // 48i. Tile preview: the picture repeats around the canvas. Shown on the first frame, which is painted all over. + const first = (await asset(id)).frames[0].id; + const firstRowAt = await json(`__gm.pixelArt.frameRow(${JSON.stringify(first)})`); + await click(firstRowAt.x, firstRowAt.y); + await settle(); + const tiles = await control('tiles'); + await click(tiles.x, tiles.y); + await settle(); + // Two pixels left of the canvas: the frame's pixel 30 in the tile beside it, painted green. + const left = await screen(-2, 2); + await shot('48-pixel-art'); + await click(tiles.x, tiles.y); + await settle(); + const leftOff = await screen(-2, 2); + check( + 'the tile preview repeats the frame beside the canvas', + colourDistance(left, HEX[GREEN]) < 10 && colourDistance(leftOff, HEX[GREEN]) > 60, + JSON.stringify({ left, leftOff }), + ); + + // 48j. Resize with the anchor in the middle: the picture keeps its place relative to it. + await press_('resize'); + let dialog = await json('__gm.pixelArt.resizeDialog()'); + await click(dialog.width.x, dialog.width.y); + await press('a', { ctrl: true }); + await typeText('40'); + await press('Enter'); + const centre = dialog.anchors['0.5,0.5']; + await click(centre.x, centre.y); + dialog = await json('__gm.pixelArt.resizeDialog()'); + await click(dialog.confirm.x, dialog.confirm.y); + await settle(); + art = await asset(id); + data = await cel(id, 0, 0); + const wide = data.slice(2 * 40, 3 * 40); + check( + 'resizing to 40 wide from the middle moves every pixel 4 to the right and keeps the frames', + art.width === 40 && + art.height === 32 && + art.frames[0].id === first && + wide[6] === RED && + wide[13] === RED && + wide[14] !== RED && + wide[0] === 0, + JSON.stringify({ width: art.width, row: wide }), + ); + await undo(); + art = await asset(id); + check( + 'one undo gives the canvas its old size back', + art.width === 32, + JSON.stringify({ width: art.width }), + ); + + // 48k. The palette: a colour changed there recolours the picture, as one undo step. + await swatch(RED); + // The field's middle is its hex text; a click there puts a caret in it, so the old value goes first. + const colorField = await control('color'); + await click(colorField.x, colorField.y); + await press('a', { ctrl: true }); + await typeText('123456'); + await press('Enter'); + await settle(); + const paletteColor = () => + json( + `(() => { const p = __gm.state.project; const a = p.assets.find((x) => x.id === ${JSON.stringify(id)});` + + ` return p.assets.find((x) => x.id === a.props.palette).colors[${RED - 1}]; })()`, + ); + const recolouredColor = await paletteColor(); + const recoloured = await screen(5, 2); + await undo(); + const restored = await paletteColor(); + check( + 'changing a palette colour recolours every pixel painted with it, and undo takes it back', + recolouredColor === 0x123456 && colourDistance(recoloured, '#123456') < 10 && restored === 0xff004d, + JSON.stringify({ recolouredColor, recoloured, restored }), + ); + + // 48l. A sprite wears the second frame; the picker offers every frame by name. + await mutate(` + const project = __gm.state.project; + const scene = __gm.state.activeScene; + ${MAKE_OBJECT} + const sprite = structuredClone(project.assets.find((a) => a.id === 'crate')); + sprite.id = 'pa-sprite'; + sprite.name = 'Pixel sprite'; + sprite.props = { ...sprite.props, width: 128, height: 128, color: 0xffffff, texture: ${JSON.stringify(second)}, shader: '' }; + project.assets.push(sprite); + scene.objects.push(make('pa-object', 'pa-sprite', '', ${Y}, 'Pixel object')); + __gm.state.select({ kind: 'asset', assetId: 'pa-sprite' })`); + await showTab('scene'); + await evaluate(`__gm.lookAt(0, ${Y})`); + await settle(); + await sleep(300); + const box = await json("__gm.objectRect('pa-object')"); + const spriteAt = (px, py) => + json( + `__gm.pixelAt(${Math.round(box.x - box.w / 2 + ((px + 0.5) / 32) * box.w)}, ${Math.round(box.y - box.h / 2 + ((py + 0.5) / 32) * box.h)})`, + ); + const spriteRed = await spriteAt(5, 2); + const spriteBlue = await spriteAt(10, 10); + check( + 'a sprite wearing the second frame shows that frame in the scene', + colourDistance(spriteRed, HEX[RED]) < 12 && colourDistance(spriteBlue, HEX[BLUE]) < 12, + JSON.stringify({ spriteRed, spriteBlue }), + ); + const textureRow = await json("__gm.inspectorField('Texture', true)"); + await click(textureRow.x, textureRow.y); + await settle(); + const offers = (await json('__gm.menuRows()')) ?? []; + await press('Escape'); + const name = + (await asset(id)) && + (await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`)); + check( + 'the Texture row offers the asset and each of its frames', + [name, `${name} / Frame 1`, `${name} / Frame 2`, `${name} / Frame 3`].every((label) => + offers.some((row) => row.endsWith(label)), + ), + JSON.stringify({ name, offers }), + ); + + // The asset's menu makes a sprite and an animation that keys every frame in order. + const artRow = await json(`__gm.assetRow(${JSON.stringify(id)})`); + await click(artRow.x, artRow.y, 'right'); + const animationEntry = await json("__gm.locate('Create animation from frames', false)"); + if (animationEntry) await click(animationEntry.x, animationEntry.y); + await settle(); + const keyed = await json(`(() => { + const p = __gm.state.project; + const s = __gm.state.selection; + const anim = s && s.kind === 'asset' ? p.assets.find((a) => a.id === s.assetId) : null; + const root = anim && p.assets.find((a) => a.id === anim.root); + return anim && anim.type === 'animation' + ? { keys: anim.tracks[0].keys.map((k) => [k.frame, k.value]), rootTexture: root && root.props.texture } + : null; + })()`); + const frameIds = (await asset(id)).frames.map((f) => f.id); + check( + 'Create animation from frames keys every frame in order on a sprite wearing the first', + animationEntry !== null && + JSON.stringify(keyed?.keys) === JSON.stringify(frameIds.map((f, i) => [i, f])) && + keyed.rootTexture === frameIds[0], + JSON.stringify({ entry: animationEntry !== null, keyed, frameIds }), + ); + + // 48m. Saved: the cels reach the server before the project does, and a reload paints them again. + await saved(); + const pending = await json('__gm.pixelArt.pending()'); + const doc = await api(`projects/${projectId}`); + const stored = doc.project.assets.find((a) => a.id === id); + const files = [...new Set(stored.frames.flatMap((f) => Object.values(f.cels)))]; + const statuses = JSON.parse( + await evaluate( + `Promise.all(${JSON.stringify(files)}.map((f) => fetch('/game-maker/data/pixels/' + f).then((r) => r.status))).then((s) => JSON.stringify(s))`, + ), + ); + check( + 'saving uploads every cel the project names, and the file names them', + pending === 0 && files.length >= 2 && statuses.every((s) => s === 200), + JSON.stringify({ pending, files: files.length, statuses }), + ); + await navigate(); + await sleep(500); + await evaluate(`__gm.openDocument('asset:' + ${JSON.stringify(id)})`); + await settle(); + await sleep(500); + const back = await screen(5, 2); + check( + 'after a reload the tab paints the saved pixels again', + (await json('__gm.pixelArt.current()')) === id && colourDistance(back, HEX[RED]) < 10, + JSON.stringify({ current: await json('__gm.pixelArt.current()'), back }), + ); + await showTab('scene'); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 784b7861..f01bee3a 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -50,6 +50,7 @@ import { documents } from './44-documents.mjs'; import { login } from './45-login.mjs'; import { topbar } from './46-topbar.mjs'; import { vectorArt } from './47-vectorart.mjs'; +import { pixelArt } from './48-pixelart.mjs'; export const STEPS = [ hierarchy, @@ -99,4 +100,5 @@ export const STEPS = [ login, topbar, vectorArt, + pixelArt, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 521b4f1c..65f67043 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -26,6 +26,7 @@ export const LANES = { queries: -4300, symbols: -4700, vectorArt: -5100, + pixelArt: -5400, }; /** @@ -115,6 +116,7 @@ export const TAB_LABELS = { shader: 'Shader', texture: 'Texture', vectorart: 'Vector art', + pixelart: 'Pixel art', animation: 'Animation', tile: 'Tile', container: 'Container', @@ -163,6 +165,7 @@ const DOCUMENT_KINDS = [ 'particles', 'texture', 'vectorart', + 'pixelart', 'animation', 'tile', 'container',