A sprite sorted by Y stood on the bottom of its box. With a shadow or empty rows under the feet that is too low, so a figure slipped in front of a tree it should stand behind. The line belongs to the frame, not the texture, because one texture can hold several sprite sheets. - New field `sortLine` on a frame: whole image pixels up from the frame's bottom edge, 0 to the frame height. 0 is stored as nothing, so old projects stay the same. Shrinking a frame keeps the line inside it. - `feetOf` in drawOrder.ts lifts a sprite by the same share of its box. Scene view, game view, play and picking all use it. In an animation the line of the frame shown right now counts. - The texture tab draws the selected frame's line dashed in yellow with a knob left of the frame. At 0 the line shares the bottom edge with the nine-slice border and a resize handle, so the knob is tested first. A line inside the frame can be dragged directly, tested after the nine-slice lines. - The inspector has a new "Sorting" section with a "Sort line" field. Dragging or typing is one undo step each. Limit: frames of one walk cycle need the same value, set by hand. Otherwise the figure changes its place in the order while walking. Tests: - ./check.sh: 160 files, 2356 tests, CHECK EXIT 0 (before 2350). - 6 new unit tests in tests/viewport/sort-by-y.test.ts. - e2e 37 and 39-43: ALL PASSED, 162 checks. New check 37b-ii drags the knob up 6 px and undoes it, plus the inspector row. - Counter checks: if feetOf ignores the line, exactly the 3 order tests fail. If the knob cannot be grabbed, 37b-ii fails. - The e2e sum in the README is 1380, counted, not yet seen in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
15 KiB
TypeScript
365 lines
15 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;
|
||
/** The frame's sort line (#228) in image pixels up from its bottom; absent is 0. */
|
||
sortLine?: number;
|
||
}
|
||
|
||
/**
|
||
* 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. */
|
||
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;
|
||
const sortLine = frame ? frameSortLine(frame) : 0;
|
||
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,
|
||
...(sortLine > 0 ? { sortLine } : {}),
|
||
};
|
||
}
|
||
|
||
/** Whole pixels from the frame's bottom edge up to its top edge at most. */
|
||
export function normalizeSortLine(value: number, h: number): number {
|
||
const line = Math.round(Number.isFinite(value) ? value : 0);
|
||
return Math.max(0, Math.min(Math.floor(h), line));
|
||
}
|
||
|
||
/** Read against the frame as it is now, so a frame resized smaller keeps its line inside it. */
|
||
export function frameSortLine(frame: TextureFrame): number {
|
||
return normalizeSortLine(frame.sortLine ?? 0, frame.h);
|
||
}
|
||
|
||
/** 0 removes the key, so a frame nobody gave a line saves as it did before. */
|
||
export function setFrameSortLine(frame: TextureFrame, value: number | undefined): void {
|
||
const line = normalizeSortLine(value ?? 0, frame.h);
|
||
if (line > 0) frame.sortLine = line;
|
||
else delete frame.sortLine;
|
||
}
|
||
|
||
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`). */
|
||
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;
|
||
}
|
||
|
||
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. */
|
||
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;
|
||
}
|
||
}
|