Textures are uploaded files named by their content, so they cannot be edited in place. Pixel art is therefore its own asset type, like vector art. Its pixels live in the project and the tab rasters them into a texture that sprites, tiles and animations can use. - New asset types Pixel art and Palette. The first pixel art of a project gets a palette with the 16 PICO-8 colours in the same undo step. Every pixel is a palette index, 0 is transparent. - Each layer on each frame is its own file data/pixels/<sha256>.px. The project only holds the names, so undo swaps names. The browser computes the hash and uploads new cels before the project is saved. - New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201 and 422 for broken data. data/pixels/ is git-ignored. - Tools: pencil, eraser, fill (contiguous or all pixels of a colour), eyedropper, line, rectangle and ellipse (outline or filled), brush size 1 to 8. - Layers can be added, deleted, renamed, reordered, hidden, faded and merged down. Frames can be added, duplicated, deleted and reordered, with onion skin and a small playing preview. - Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and Resize... with a 3x3 anchor. - All frames sit side by side in one image. Each frame is a region like a cut texture frame, so texture rows offer "Asset / Frame 2" and the context menu has Create animation from frames. Decisions: - Merging down drops the opacity of the upper layer. Indexed colours know no blends. - Only the last palette colour can be removed. Otherwise the indices of every image using the palette would shift. - Undo stores the whole layer per stroke instead of pixel diffs. - Alt+click picks a colour. It is a mouse gesture, not a shortcut. Follow-up issues: #197 import from texture, #198 selection and flipping, #199 PNG export, #200 more tools. Tests: - ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in tests/core/pixel-art.test.ts, 4 from the icon tables. - e2e module 48 fresh: ALL PASSED, 29 checks, console empty. - Counter-probes bite: five on the unit tests, upload order (2 tests fail) and cels not uploaded in the browser (1 check fails). - Check total 1259 in README is computed (1232 measured in CI + 27). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
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';
|
||
|
||
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
|
||
const FILE_PATTERN = /^[a-f0-9]{64}\.(png|jpg|webp|gif)$/;
|
||
|
||
export function isTextureFile(name: string): boolean {
|
||
return FILE_PATTERN.test(name);
|
||
}
|
||
|
||
export interface FrameRect {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
export const MIN_FRAME_SIZE = 1;
|
||
|
||
/**
|
||
* `rect` is null for the whole image rather than a rectangle covering it: the renderer can then
|
||
* skip the texture matrix (Pixi fits an unmatrixed fill to the shape) and the meaning survives
|
||
* the image being re-uploaded at a different size.
|
||
*/
|
||
export interface TextureRef {
|
||
file: string;
|
||
smooth: boolean;
|
||
rect: FrameRect | null;
|
||
sourceWidth: number;
|
||
sourceHeight: number;
|
||
/** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */
|
||
slice: NineSlice | null;
|
||
}
|
||
|
||
/**
|
||
* A sliced frame as the renderer needs it: the borders, how the parts between them fill, and the
|
||
* size they are measured in. Carried on the ref rather than looked up while drawing, for the same
|
||
* reason the rectangle is – drawing has no access to the project.
|
||
*/
|
||
export interface NineSlice {
|
||
border: FrameBorders;
|
||
fill: FrameFill;
|
||
/** Size of the frame in image pixels; the borders are distances inside it. */
|
||
sourceW: number;
|
||
sourceH: number;
|
||
/** Sprite units one border pixel is drawn with – the texture's, shared by all its frames (#96). */
|
||
scale: number;
|
||
}
|
||
|
||
export const SLICE_SCALE_KEY = 'sliceScale';
|
||
|
||
/** The texture's nine-slice scale; anything that is not a size above zero reads as the default 1. */
|
||
export function sliceScaleOf(asset: TextureAsset): number {
|
||
const value = Number(asset.props[SLICE_SCALE_KEY] ?? 1);
|
||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||
}
|
||
|
||
export interface FrameRef {
|
||
asset: TextureAsset;
|
||
frame: TextureFrame;
|
||
}
|
||
|
||
/**
|
||
* Frame ids share the generator with asset ids and are unique across the document, which lets a
|
||
* sprite's `texture` property hold either: an asset id means the whole picture, a frame id a cut-out.
|
||
*/
|
||
export function findFrame(project: Project, frameId: string): FrameRef | null {
|
||
if (!frameId) return null;
|
||
for (const asset of project.assets) {
|
||
if (asset.type !== 'texture') continue;
|
||
const frame = asset.frames.find((f) => f.id === frameId);
|
||
if (frame) return { asset, frame };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Every id that means "this picture": the texture asset itself and each of its frames. */
|
||
export function textureRefIds(asset: TextureAsset): string[] {
|
||
return [asset.id, ...asset.frames.map((f) => f.id)];
|
||
}
|
||
|
||
/** What a sprite wearing `props.texture` shows. */
|
||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||
return resolvePicture(project, textOf(props.texture ?? ''));
|
||
}
|
||
|
||
/** A texture, a frame or a generating asset; any other asset's id is no picture. */
|
||
export function resolvePicture(project: Project, id: string): TextureRef | null {
|
||
if (!id) return null;
|
||
const asset = findAsset(project, id);
|
||
if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset, project);
|
||
const found = findFrame(project, id);
|
||
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 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, 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,
|
||
sourceWidth: picture.width,
|
||
sourceHeight: picture.height,
|
||
slice: null,
|
||
};
|
||
}
|
||
|
||
function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null {
|
||
if (!isTextureFile(asset.file)) return null;
|
||
return {
|
||
file: asset.file,
|
||
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
|
||
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
|
||
sourceWidth: asset.width,
|
||
sourceHeight: asset.height,
|
||
slice: frame ? nineSliceOf(frame, sliceScaleOf(asset)) : null,
|
||
};
|
||
}
|
||
|
||
export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
|
||
export const DEFAULT_FILL: FrameFill = 'repeat';
|
||
|
||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
||
export const MIN_SLICE_MIDDLE = 1;
|
||
|
||
/** The frame's collision outlines (#141), a copy; empty when it has none. */
|
||
export function frameCollision(frame: TextureFrame): Vec2[][] {
|
||
return (frame.collision ?? []).map((outline) => outline.map((p) => ({ ...p })));
|
||
}
|
||
|
||
/**
|
||
* Sets the outlines. One without corners is dropped, and none left removes the key, so a frame
|
||
* without an outline keeps saying so; one being drawn, with a corner or two, is kept.
|
||
*/
|
||
export function setFrameCollision(frame: TextureFrame, outlines: readonly (readonly Vec2[])[]): void {
|
||
const kept = outlines.filter((outline) => outline.length > 0);
|
||
if (kept.length === 0) delete frame.collision;
|
||
else frame.collision = kept.map((outline) => outline.map((p) => ({ x: p.x, y: p.y })));
|
||
}
|
||
|
||
export function frameBorders(frame: TextureFrame): FrameBorders {
|
||
return frame.border ? { ...frame.border } : { ...NO_BORDERS };
|
||
}
|
||
|
||
export function frameFill(frame: TextureFrame): FrameFill {
|
||
return frame.fill === 'stretch' ? 'stretch' : DEFAULT_FILL;
|
||
}
|
||
|
||
export function hasBorders(border: FrameBorders): boolean {
|
||
return border.left > 0 || border.top > 0 || border.right > 0 || border.bottom > 0;
|
||
}
|
||
|
||
/** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */
|
||
export function nineSliceOf(frame: TextureFrame, scale = 1): NineSlice | null {
|
||
const border = normalizeBorders(frameBorders(frame), frame.w, frame.h);
|
||
if (!hasBorders(border)) return null;
|
||
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale };
|
||
}
|
||
|
||
/**
|
||
* Whole pixels, never negative, and never eating the whole frame: two opposite borders are scaled
|
||
* down together until at least one pixel is left between them. A middle of no pixels has nothing
|
||
* to draw the space between the corners with, which would show as a hole rather than as an error.
|
||
*/
|
||
export function normalizeBorders(border: FrameBorders, w: number, h: number): FrameBorders {
|
||
const [left, right] = fitPair(border.left, border.right, w);
|
||
const [top, bottom] = fitPair(border.top, border.bottom, h);
|
||
return { left, top, right, bottom };
|
||
}
|
||
|
||
function fitPair(near: number, far: number, size: number): [number, number] {
|
||
const a = Math.max(0, Math.round(Number.isFinite(near) ? near : 0));
|
||
const b = Math.max(0, Math.round(Number.isFinite(far) ? far : 0));
|
||
const room = Math.max(0, Math.floor(size) - MIN_SLICE_MIDDLE);
|
||
if (a + b <= room) return [a, b];
|
||
if (room <= 0) return [0, 0];
|
||
// Proportional, so a frame that is dragged small keeps the shape of its corners.
|
||
const scaled = Math.round((a / (a + b)) * room);
|
||
return [scaled, room - scaled];
|
||
}
|
||
|
||
/** The slice settings of a frame, as one value – what an inspector row and a command pass around. */
|
||
export interface FrameSlice {
|
||
border: FrameBorders;
|
||
fill: FrameFill;
|
||
}
|
||
|
||
export function frameSlice(frame: TextureFrame): FrameSlice {
|
||
return { border: frameBorders(frame), fill: frameFill(frame) };
|
||
}
|
||
|
||
/**
|
||
* Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced
|
||
* reads and saves exactly as it did before #41, so undo of the first slice restores the old shape.
|
||
*/
|
||
export function setFrameSlice(frame: TextureFrame, slice: FrameSlice): void {
|
||
const border = normalizeBorders(slice.border, frame.w, frame.h);
|
||
if (hasBorders(border)) frame.border = border;
|
||
else delete frame.border;
|
||
// Only the fill that differs from `DEFAULT_FILL` is written. Kept even without borders: a fill
|
||
// chosen before the borders are dragged would otherwise snap back under the hand that just set it.
|
||
if (slice.fill !== DEFAULT_FILL) frame.fill = slice.fill;
|
||
else delete frame.fill;
|
||
}
|
||
|
||
/** The slice keys exactly as the frame carries them – absence included, which undo owes back. */
|
||
export function rawFrameSlice(frame: TextureFrame): Pick<TextureFrame, 'border' | 'fill'> {
|
||
const raw: Pick<TextureFrame, 'border' | 'fill'> = {};
|
||
if (frame.border) raw.border = { ...frame.border };
|
||
if (frame.fill) raw.fill = frame.fill;
|
||
return raw;
|
||
}
|
||
|
||
export function restoreFrameSlice(frame: TextureFrame, raw: Pick<TextureFrame, 'border' | 'fill'>): void {
|
||
if (raw.border) frame.border = { ...raw.border };
|
||
else delete frame.border;
|
||
if (raw.fill) frame.fill = raw.fill;
|
||
else delete frame.fill;
|
||
}
|
||
|
||
/**
|
||
* Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples
|
||
* half of a neighbouring pixel along every edge, the classic sprite-sheet seam.
|
||
*/
|
||
export function normalizeFrameRect(rect: FrameRect, imageWidth: number, imageHeight: number): FrameRect {
|
||
// An image of unknown size (upload still running) cannot constrain anything; only round.
|
||
const limitX = imageWidth > 0 ? imageWidth : Infinity;
|
||
const limitY = imageHeight > 0 ? imageHeight : Infinity;
|
||
const x0 = clampInt(Math.min(rect.x, rect.x + rect.w), 0, limitX - MIN_FRAME_SIZE);
|
||
const y0 = clampInt(Math.min(rect.y, rect.y + rect.h), 0, limitY - MIN_FRAME_SIZE);
|
||
const x1 = clampInt(Math.max(rect.x, rect.x + rect.w), x0 + MIN_FRAME_SIZE, limitX);
|
||
const y1 = clampInt(Math.max(rect.y, rect.y + rect.h), y0 + MIN_FRAME_SIZE, limitY);
|
||
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
||
}
|
||
|
||
function clampInt(value: number, min: number, max: number): number {
|
||
const n = Math.round(Number.isFinite(value) ? value : 0);
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
|
||
export interface SliceOptions {
|
||
columns: number;
|
||
rows: number;
|
||
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
|
||
offsetX: number;
|
||
offsetY: number;
|
||
spacingX: number;
|
||
spacingY: number;
|
||
}
|
||
|
||
export const DEFAULT_SLICE: SliceOptions = {
|
||
columns: 4,
|
||
rows: 4,
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
spacingX: 0,
|
||
spacingY: 0,
|
||
};
|
||
|
||
/**
|
||
* The rectangles a grid cuts a sheet into, in reading order. Cell size is derived from the image;
|
||
* a cell that would reach past the edge is dropped rather than clipped.
|
||
*/
|
||
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
|
||
const columns = Math.floor(options.columns);
|
||
const rows = Math.floor(options.rows);
|
||
// NaN passes `columns < 1` (every comparison against it is false), so reject it explicitly.
|
||
if (!Number.isFinite(columns) || !Number.isFinite(rows)) return [];
|
||
if (!Object.values(options).every((value) => Number.isFinite(value))) return [];
|
||
if (columns < 1 || rows < 1 || imageWidth <= 0 || imageHeight <= 0) return [];
|
||
const cellW = Math.floor((imageWidth - options.offsetX - options.spacingX * (columns - 1)) / columns);
|
||
const cellH = Math.floor((imageHeight - options.offsetY - options.spacingY * (rows - 1)) / rows);
|
||
if (cellW < MIN_FRAME_SIZE || cellH < MIN_FRAME_SIZE) return [];
|
||
const rects: FrameRect[] = [];
|
||
for (let row = 0; row < rows; row++) {
|
||
for (let column = 0; column < columns; column++) {
|
||
const x = options.offsetX + column * (cellW + options.spacingX);
|
||
const y = options.offsetY + row * (cellH + options.spacingY);
|
||
if (x + cellW > imageWidth || y + cellH > imageHeight) continue;
|
||
rects.push({ x, y, w: cellW, h: cellH });
|
||
}
|
||
}
|
||
return rects;
|
||
}
|
||
|
||
export function frameRect(frame: TextureFrame): FrameRect {
|
||
return { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
|
||
}
|
||
|
||
export function setFrameRect(frame: TextureFrame, rect: FrameRect): void {
|
||
frame.x = rect.x;
|
||
frame.y = rect.y;
|
||
frame.w = rect.w;
|
||
frame.h = rect.h;
|
||
}
|
||
|
||
/** A name free within the texture: `base`, else "base 2", "base 3" … – the shape `nextAssetName` produces. */
|
||
export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''): string {
|
||
const taken = takenFrameNames(asset, ignoreId);
|
||
const stem = base || 'Frame';
|
||
return taken.has(stem) ? firstFreeName(taken, stem, 2) : stem;
|
||
}
|
||
|
||
/**
|
||
* `count` free names numbered from 1. Taken numbers are skipped rather than suffixed again, so
|
||
* re-slicing a sheet that keeps its old frames cannot produce "Frame 2 1".
|
||
*/
|
||
export function nextFrameNames(asset: TextureAsset, count: number, base = 'Frame'): string[] {
|
||
const taken = takenFrameNames(asset);
|
||
const names: string[] = [];
|
||
let n = 1;
|
||
for (let i = 0; i < count; i++) {
|
||
const name = firstFreeName(taken, base, n);
|
||
taken.add(name);
|
||
names.push(name);
|
||
n = Number(name.slice(base.length + 1)) + 1;
|
||
}
|
||
return names;
|
||
}
|
||
|
||
function takenFrameNames(asset: TextureAsset, ignoreId = ''): Set<string> {
|
||
return new Set(asset.frames.filter((f) => f.id !== ignoreId).map((f) => f.name));
|
||
}
|
||
|
||
function firstFreeName(taken: ReadonlySet<string>, base: string, from: number): string {
|
||
for (let i = from; ; i++) {
|
||
const candidate = `${base} ${i}`;
|
||
if (!taken.has(candidate)) return candidate;
|
||
}
|
||
}
|