/** * 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 { copyName } from '../util/names'; 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); } 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. */ 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; } /** * A copy of a layer right above it, named after it, with its cels on every frame. The cels are files * named by their content, so the copy names the same files and costs no upload. */ export function withLayerDuplicated(state: PixelArtState, id: string, sourceId: string): PixelArtState { const next = cloneState(state); const index = layerIndex(next, sourceId); const source = next.layers[index]!; const name = copyName( next.layers.map((layer) => layer.name), source.name, ); next.layers.splice(index + 1, 0, { ...source, id, name }); for (const frame of next.frames) { const cel = frame.cels[sourceId]; if (cel) frame.cels[id] = cel; } 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`); for (const frame of state.frames) for (const file of Object.values(frame.cels)) if (cels[file] === undefined) throw new PixelEditError('A cel of the old canvas has no counterpart'); return { ...withCelsReplaced(state, cels), width, height }; } /** Every cel `cels` names swapped for the file it maps to, `''` leaving it empty; the others stay. */ export function withCelsReplaced( state: PixelArtState, cels: Readonly>, ): PixelArtState { const next = cloneState(state); for (const frame of next.frames) for (const [layerId, file] of Object.entries(frame.cels)) { const replaced = cels[file]; if (replaced === undefined) continue; if (replaced) frame.cels[layerId] = replaced; else delete frame.cels[layerId]; } return next; } function isCanvasSide(side: number): boolean { return Number.isInteger(side) && side >= 1 && side <= PIXEL_CANVAS_MAX; }