Files
feuerwerk/src/core/model/texture.ts
T
adminandClaude Opus 5 bf0be66c9d
game-maker / gate (push) Successful in 1m32s
game-maker / suite (push) Successful in 5m47s
A frame with borders tiles unless told to stretch
The fill of a sliced frame defaulted to `Stretch`. It is `Repeat` now, and the Fill dropdown lists
`Repeat` first.

The saved document keeps writing only the fill that differs from the default, so the key it
carries is now `stretch`, and a frame without it repeats. A frame saved before with the key
absent and meaning stretch would therefore tile; none of the stored projects has one - both sliced
frames on the server carry `fill: "repeat"` explicitly, so they draw as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:13:11 +02:00

315 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { animationFrameAt } from './animation';
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
import { type PropertyBag, textOf } 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; an animation shows its frame at `time` seconds
* since it started.
*/
export function resolveTexture(project: Project, props: PropertyBag, time = 0): TextureRef | null {
const id = textOf(props.texture ?? '');
const asset = id ? findAsset(project, id) : undefined;
if (asset?.type === 'animation') {
const index = animationFrameAt(asset, time).index;
return index < 0 ? null : resolvePicture(project, asset.frames[index]!);
}
return resolvePicture(project, id);
}
/** A texture or frame id. An animation is not a picture, which keeps one inside another from resolving. */
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) : null;
const found = findFrame(project, id);
return found ? refFor(found.asset, found.frame) : 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;
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;
}
}