From 66d6aafbb42d25435815804498c00d08d6facfde Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 21 Sep 2026 17:33:44 +0200 Subject: [PATCH] Pixel art can be drawn in its own tab with layers and frames (#130) Textures are uploaded files named by their content, so they cannot be edited in place. Pixel art is therefore its own asset type, like vector art. Its pixels live in the project and the tab rasters them into a texture that sprites, tiles and animations can use. - New asset types Pixel art and Palette. The first pixel art of a project gets a palette with the 16 PICO-8 colours in the same undo step. Every pixel is a palette index, 0 is transparent. - Each layer on each frame is its own file data/pixels/.px. The project only holds the names, so undo swaps names. The browser computes the hash and uploads new cels before the project is saved. - New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201 and 422 for broken data. data/pixels/ is git-ignored. - Tools: pencil, eraser, fill (contiguous or all pixels of a colour), eyedropper, line, rectangle and ellipse (outline or filled), brush size 1 to 8. - Layers can be added, deleted, renamed, reordered, hidden, faded and merged down. Frames can be added, duplicated, deleted and reordered, with onion skin and a small playing preview. - Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and Resize... with a 3x3 anchor. - All frames sit side by side in one image. Each frame is a region like a cut texture frame, so texture rows offer "Asset / Frame 2" and the context menu has Create animation from frames. Decisions: - Merging down drops the opacity of the upper layer. Indexed colours know no blends. - Only the last palette colour can be removed. Otherwise the indices of every image using the palette would shift. - Undo stores the whole layer per stroke instead of pixel diffs. - Alt+click picks a colour. It is a mouse gesture, not a shortcut. Follow-up issues: #197 import from texture, #198 selection and flipping, #199 PNG export, #200 more tools. Tests: - ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in tests/core/pixel-art.test.ts, 4 from the icon tables. - e2e module 48 fresh: ALL PASSED, 29 checks, console empty. - Counter-probes bite: five on the unit tests, upload order (2 tests fail) and cels not uploaded in the browser (1 check fails). - Check total 1259 in README is computed (1232 measured in CI + 27). Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + README.md | 2 +- api/index.php | 28 +- api/lib/PixelStore.php | 73 +++ src/core/commands/assetCommands.ts | 13 + src/core/commands/pixelArtCommands.ts | 85 ++++ src/core/model/Asset.ts | 47 ++ src/core/model/AssetTypes.ts | 8 + src/core/model/pixelCel.ts | 133 +++++ src/core/model/pixelDraw.ts | 172 +++++++ src/core/model/pixelEdit.ts | 204 ++++++++ src/core/model/pixelart.ts | 220 ++++++++ src/core/model/texture.ts | 14 +- src/core/properties/PropertySchema.ts | 18 + src/core/properties/textureRef.ts | 18 +- src/core/store/HttpFileStore.ts | 7 +- src/core/store/serialize.ts | 70 +++ src/editor/App.ts | 12 +- src/editor/Editor.ts | 9 + src/editor/actions.ts | 8 +- src/editor/assetTabs.ts | 1 + src/editor/debugHook.ts | 103 ++++ src/editor/panels/AssetPanel.ts | 37 +- src/editor/panels/assetGroups.ts | 3 + src/editor/panels/assetIcons.ts | 4 + src/editor/panels/icons/palette.svg | 7 + src/editor/panels/icons/pixelart.svg | 4 + src/editor/pixelart/PaletteGrid.ts | 79 +++ src/editor/pixelart/PixelArtCanvas.ts | 559 +++++++++++++++++++++ src/editor/pixelart/PixelArtEditor.ts | 84 ++++ src/editor/pixelart/PixelArtSide.ts | 370 ++++++++++++++ src/editor/pixelart/PixelPreview.ts | 74 +++ src/editor/pixelart/ResizeCanvasDialog.ts | 96 ++++ src/editor/pixelart/celUploadStore.ts | 37 ++ src/editor/pixelart/pixelActions.ts | 187 +++++++ src/editor/pixelart/pixelView.ts | 44 ++ src/viewport/FileLibrary.ts | 12 +- src/viewport/PixelCels.ts | 88 ++++ src/viewport/pixelArtPicture.ts | 43 ++ tests/core/pixel-art.test.ts | 578 ++++++++++++++++++++++ tools/e2e/48-pixelart.mjs | 485 ++++++++++++++++++ tools/e2e/index.mjs | 2 + tools/e2e/lib/scaffold.mjs | 3 + 43 files changed, 4018 insertions(+), 24 deletions(-) create mode 100644 api/lib/PixelStore.php create mode 100644 src/core/commands/pixelArtCommands.ts create mode 100644 src/core/model/pixelCel.ts create mode 100644 src/core/model/pixelDraw.ts create mode 100644 src/core/model/pixelEdit.ts create mode 100644 src/core/model/pixelart.ts create mode 100644 src/editor/panels/icons/palette.svg create mode 100644 src/editor/panels/icons/pixelart.svg create mode 100644 src/editor/pixelart/PaletteGrid.ts create mode 100644 src/editor/pixelart/PixelArtCanvas.ts create mode 100644 src/editor/pixelart/PixelArtEditor.ts create mode 100644 src/editor/pixelart/PixelArtSide.ts create mode 100644 src/editor/pixelart/PixelPreview.ts create mode 100644 src/editor/pixelart/ResizeCanvasDialog.ts create mode 100644 src/editor/pixelart/celUploadStore.ts create mode 100644 src/editor/pixelart/pixelActions.ts create mode 100644 src/editor/pixelart/pixelView.ts create mode 100644 src/viewport/PixelCels.ts create mode 100644 src/viewport/pixelArtPicture.ts create mode 100644 tests/core/pixel-art.test.ts create mode 100644 tools/e2e/48-pixelart.mjs 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',