An animation asset is a list of pictures (whole textures or frames) with frames per second and Loop/Once/Ping-pong playback. A sprite wears it through its texture property, so the picker, instance overrides and the Properties node work unchanged; there is no dedicated play node. A different animation starts from its first frame, the same one set again runs on, so a graph may choose Run or Idle every frame. The clock lives on the entity, not in props, which the world writes back as overrides. The AnimationSystem ticks before behaviors, and On animation finished fires once when a Once animation reaches its end. The Animation tab plays a preview above a frame strip: reorder by dragging, Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer with pan and Fit. Scene and Container tabs show the first frame; edit mode has no clock. Right-clicking a texture creates an animation from its frames. Button now measures its label with the bold font it draws in, which cut the Animation tab and red dialog buttons short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
7.4 KiB
TypeScript
204 lines
7.4 KiB
TypeScript
import { animationFrameAt } from './animation';
|
||
import type { 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;
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
/** Pixels between neighbouring cells. */
|
||
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;
|
||
}
|
||
}
|