Part (a) of the animation rework: model, commands, saving, runtime and nodes. - AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one property of the root or of an object inside it, keys sit on whole frames. - The last key decides the length (its frame counts as a frame of its own). The playhead runs between frames, so numbers and colors blend every tick even at 10 fps; everything else holds. - Playback gains `once-reset`, which puts back what was there before the start. A row can be relative, adding to where its target stood. - Play / Pause / Stop animation act on an object; each instance plays on its own, the later start wins on a shared property, and a behavior writing the same property still writes last. - A sprite no longer wears an animation through `texture`. "Create animation from texture" builds a sprite plus a texture row keyed frame by frame instead. - The Animation tab shows a placeholder until the timeline editor (part b) lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
66 lines
2.6 KiB
TypeScript
66 lines
2.6 KiB
TypeScript
import type { PropertyBag } from '../model/Properties';
|
||
import type { Project } from '../model/Project';
|
||
import { resolveTexture, type TextureRef } from '../model/texture';
|
||
|
||
export type SizeAxis = 'width' | 'height';
|
||
|
||
export interface PixelSize {
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
/**
|
||
* Pixel size of the picture a sprite wears. Null while that
|
||
* is not known: an upload still running has no measured size yet, and a ratio taken from 0 × 0
|
||
* would be invented.
|
||
*/
|
||
export function texturePixelSize(project: Project, props: PropertyBag): PixelSize | null {
|
||
const ref = resolveTexture(project, props);
|
||
return ref ? pictureSize(ref) : null;
|
||
}
|
||
|
||
/** A frame's rectangle, else the whole image. */
|
||
export function pictureSize(ref: TextureRef): PixelSize | null {
|
||
const w = ref.rect ? ref.rect.w : ref.sourceWidth;
|
||
const h = ref.rect ? ref.rect.h : ref.sourceHeight;
|
||
return w > 0 && h > 0 ? { w, h } : null;
|
||
}
|
||
|
||
/** A sprite wearing the texture at its own pixel size – or at the default size while that is unknown. */
|
||
export function spriteFromTexture(project: Project, textureId: string): PropertyBag {
|
||
const props: PropertyBag = { texture: textureId };
|
||
const size = texturePixelSize(project, props);
|
||
return size ? { ...props, width: size.w, height: size.h } : props;
|
||
}
|
||
|
||
/**
|
||
* What `axis` has to become for the sprite to carry the picture's proportions, leaving the
|
||
* other side where it is. Null when the other side is not a usable number.
|
||
*/
|
||
export function fitAxisToAspect(size: PixelSize, props: PropertyBag, axis: SizeAxis): number | null {
|
||
const other = Number(props[axis === 'width' ? 'height' : 'width']);
|
||
if (!Number.isFinite(other) || other <= 0) return null;
|
||
const fitted = axis === 'width' ? (other * size.w) / size.h : (other * size.h) / size.w;
|
||
return Number.isFinite(fitted) && fitted > 0 ? fitted : null;
|
||
}
|
||
|
||
/**
|
||
* Whether the sprite is drawn at the picture's own pixel size – one image pixel per world unit.
|
||
* Exact, unlike `matchesAspect`: these two numbers are written as they are, with no division in
|
||
* between that could cost a digit.
|
||
*/
|
||
export function matchesPixelSize(size: PixelSize, props: PropertyBag): boolean {
|
||
return Number(props.width) === size.w && Number(props.height) === size.h;
|
||
}
|
||
|
||
/**
|
||
* Cross product with a relative tolerance, because 3135 of the 40 000 sizes up to 200 × 200 fail an
|
||
* exact test right after their own fit (rounding).
|
||
*/
|
||
export function matchesAspect(size: PixelSize, props: PropertyBag): boolean {
|
||
const w = Number(props.width);
|
||
const h = Number(props.height);
|
||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return false;
|
||
return Math.abs(w * size.h - h * size.w) <= 1e-9 * w * size.h;
|
||
}
|