Add a Texture tab for defining frames
A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets frames be drawn on it: drag to create, move, resize via eight handles, edit name and rect in the inspector, Delete to remove. Every change is a command, so one gesture is one undo step. "Slice..." cuts a grid from columns and rows with optional offset and spacing, previewing the resulting frame count and cell size live. Sprites pick a frame instead of the whole texture through the same `texture` property: it now holds either a texture id or a frame id. Frame ids come from the same generator and are document-wide unique, so one picker, one property and one set of instance overrides cover both cases, and every sprite written before this still means the same thing. A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image, because it derives its uvs from the shape bounds and the inverted style matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which keeps a sheet at a single GPU upload no matter how many of its frames are on screen at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a6af74e44
commit
35a1a23af8
29 files changed
+2650
-53
No files matched your search
@@ -22,7 +22,7 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
const base = { id, name, props: defaultsFor(type) };
|
||||
switch (type) {
|
||||
case 'texture':
|
||||
return { ...base, type: 'texture', file: '', width: 0, height: 0 };
|
||||
return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id);
|
||||
case 'scene':
|
||||
return { ...base, type: 'scene', objects: [] };
|
||||
case 'camera':
|
||||
@@ -70,6 +70,15 @@ export interface TextureImage {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture asset for a stored image, without frames – slicing it is a separate, undoable
|
||||
* edit. Kept apart from `buildAsset` so callers that need a `TextureAsset` get one, instead
|
||||
* of the `Asset` union they would have to narrow again.
|
||||
*/
|
||||
export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
|
||||
return { id, name, props: defaultsFor('texture'), type: 'texture', ...image, frames: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a texture asset for an image that is already stored on the server. Uploading is I/O and
|
||||
* therefore happens before the command: this one only records the result, so undo and redo of
|
||||
@@ -80,7 +89,7 @@ export class CreateTextureCommand implements Command {
|
||||
readonly asset: TextureAsset;
|
||||
|
||||
constructor(name: string, image: TextureImage) {
|
||||
this.asset = { ...buildAsset('texture', name), type: 'texture', ...image };
|
||||
this.asset = buildTextureAsset(name, image);
|
||||
this.label = `Import texture "${name}"`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Undoable edits of a texture's frames – the named rectangles a sprite sheet is cut into
|
||||
* (see `model/texture.ts`). Every rectangle that enters the model passes `normalizeFrameRect`
|
||||
* here rather than at the call site: frames come from dragging in a zoomed view, so callers
|
||||
* produce fractional and inverted rectangles as a matter of course.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import type { TextureAsset, TextureFrame } from '../model/Asset';
|
||||
import type { Project } from '../model/Project';
|
||||
import {
|
||||
frameRect,
|
||||
type FrameRect,
|
||||
nextFrameNames,
|
||||
normalizeFrameRect,
|
||||
setFrameRect,
|
||||
sliceFrames,
|
||||
type SliceOptions,
|
||||
uniqueFrameName,
|
||||
} from '../model/texture';
|
||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './require';
|
||||
|
||||
/** The name new frames are derived from when the caller has nothing better to offer. */
|
||||
const DEFAULT_NAME = 'Frame';
|
||||
|
||||
/** The asset as a texture, rejecting both "no such asset" and "that one is not a picture". */
|
||||
function requireTexture(project: Project, assetId: string): TextureAsset {
|
||||
const asset = requireAsset(project, assetId);
|
||||
if (asset.type !== 'texture') throw new CommandError(`${asset.name} is not a texture`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
function requireFrame(asset: TextureAsset, frameId: string): TextureFrame {
|
||||
const frame = asset.frames.find((f) => f.id === frameId);
|
||||
if (!frame) throw new CommandError(`Frame ${frameId} not found in ${asset.name}`);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one frame to a texture. The id is known before execution, which is what lets the caller
|
||||
* select the new frame right after executing – the same trick `PlaceAssetCommand` uses.
|
||||
*/
|
||||
export class AddFrameCommand implements Command {
|
||||
readonly label = 'Add frame';
|
||||
readonly frameId = newId();
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly rect: FrameRect,
|
||||
private readonly name = DEFAULT_NAME,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
asset.frames.push({
|
||||
id: this.frameId,
|
||||
// Resolved on every execution rather than in the constructor: a redo has to fit the
|
||||
// names that exist when it runs, not the ones that existed when the command was built.
|
||||
name: uniqueFrameName(asset, this.name),
|
||||
...normalizeFrameRect(this.rect, asset.width, asset.height),
|
||||
});
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
asset.frames = removeById(asset.frames, this.frameId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves and resizes a frame – one command per drag, not one per pointer move.
|
||||
*
|
||||
* A drag previews itself by writing into the frame directly; on release the caller puts the
|
||||
* starting rectangle back and executes this once, exactly as `SceneViewport` does for
|
||||
* `MoveSceneObjectCommand`. The rectangle to revert to is therefore read from the model at
|
||||
* execution time: a caller that forgets to undo its preview would record the preview as the
|
||||
* state to return to, and the whole drag would collapse into one undo step of nothing.
|
||||
*/
|
||||
export class SetFrameRectCommand implements Command {
|
||||
readonly label = 'Move frame';
|
||||
private previous: FrameRect = { x: 0, y: 0, w: 0, h: 0 };
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly frameId: string,
|
||||
private readonly rect: FrameRect,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
const frame = requireFrame(asset, this.frameId);
|
||||
this.previous = frameRect(frame);
|
||||
setFrameRect(frame, normalizeFrameRect(this.rect, asset.width, asset.height));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
// Restored verbatim instead of normalized again: undo owes the user the state that was
|
||||
// there, even if it came out of a document written before the image was measured.
|
||||
setFrameRect(requireFrame(asset, this.frameId), this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames a frame. The name is what the sprite's texture picker shows, so it may not be blank. */
|
||||
export class RenameFrameCommand implements Command {
|
||||
readonly label = 'Rename frame';
|
||||
private previous = '';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly frameId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
const frame = requireFrame(asset, this.frameId);
|
||||
const name = this.name.trim();
|
||||
if (!name) throw new CommandError('A frame needs a name');
|
||||
this.previous = frame.name;
|
||||
frame.name = uniqueFrameName(asset, name, this.frameId);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireFrame(requireTexture(project, this.assetId), this.frameId).name = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a frame, putting it back at its former index on undo – the frame order is the order
|
||||
* of the sheet, so appending it again would silently reshuffle the picture.
|
||||
*
|
||||
* Sprites wearing the frame are left pointing at it. A dangling reference reads as "no
|
||||
* picture" everywhere (`resolveTexture`) and comes back by itself when this is undone, which
|
||||
* clearing the references could not offer.
|
||||
*/
|
||||
export class RemoveFrameCommand implements Command {
|
||||
readonly label = 'Remove frame';
|
||||
private removed?: Removed<TextureFrame>;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly frameId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
const removed = removeFirst(asset.frames, (f) => f.id === this.frameId);
|
||||
if (!removed) throw new CommandError(`Frame ${this.frameId} not found in ${asset.name}`);
|
||||
this.removed = removed;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
restoreAt(requireTexture(project, this.assetId).frames, [this.removed]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts a sheet into a grid of frames (see `sliceFrames`), either adding them to the frames
|
||||
* already there or replacing those.
|
||||
*
|
||||
* A grid that yields nothing is rejected rather than applied as an empty change: it means the
|
||||
* image has not been measured yet or the cells came out smaller than a pixel, and both look to
|
||||
* the user like the Slice button doing nothing at all.
|
||||
*/
|
||||
export class SliceFramesCommand implements Command {
|
||||
readonly label: string;
|
||||
private created: TextureFrame[] = [];
|
||||
private previous: TextureFrame[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly options: SliceOptions,
|
||||
private readonly replace: boolean,
|
||||
) {
|
||||
this.label = replace ? 'Re-slice frames' : 'Slice frames';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
if (asset.width <= 0 || asset.height <= 0)
|
||||
throw new CommandError(`The size of ${asset.name} is not known, so it cannot be sliced`);
|
||||
const rects = sliceFrames(asset.width, asset.height, this.options);
|
||||
if (rects.length === 0)
|
||||
throw new CommandError(`${this.options.columns}×${this.options.rows} does not fit ${asset.name}`);
|
||||
|
||||
this.previous = [...asset.frames];
|
||||
if (this.replace) asset.frames = [];
|
||||
// A redo reuses the frames the first run created: while the command sat on the undo stack
|
||||
// a sprite may have been pointed at one of them, and a fresh id would break that.
|
||||
if (this.created.length === 0) this.created = this.buildFrames(asset, rects);
|
||||
else asset.frames.push(...this.created);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireTexture(project, this.assetId).frames = this.previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frames named after their position in the sheet ("Frame 1" is the first cell in reading
|
||||
* order). The whole run of names is asked for at once, so that keeping the existing frames
|
||||
* shifts the numbering past them instead of suffixing a number onto a number.
|
||||
*/
|
||||
private buildFrames(asset: TextureAsset, rects: readonly FrameRect[]): TextureFrame[] {
|
||||
const names = nextFrameNames(asset, rects.length, DEFAULT_NAME);
|
||||
const created = rects.map((rect, index) => ({
|
||||
id: newId(),
|
||||
name: names[index]!,
|
||||
// A no-op for a whole-pixel grid, but the offsets come from a form and may be anything.
|
||||
...normalizeFrameRect(rect, asset.width, asset.height),
|
||||
}));
|
||||
asset.frames.push(...created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -65,6 +65,22 @@ export interface ContainerAsset extends AssetBase {
|
||||
objects: SceneObject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One named rectangle of a texture, in image pixels – a frame of a sprite sheet.
|
||||
*
|
||||
* Its `id` is drawn from the same generator as asset ids and is unique across the document,
|
||||
* which is what lets a sprite's `texture` property name either a whole texture or one frame
|
||||
* of it (see `model/texture.ts`).
|
||||
*/
|
||||
export interface TextureFrame {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded image. `file` names it in `data/textures/` and is content-addressed
|
||||
* (`{sha256}.{ext}`), so the same picture uploaded twice is one file and a stored file can be
|
||||
@@ -75,15 +91,21 @@ export interface ContainerAsset extends AssetBase {
|
||||
* is the reference to it – sprites carry `texture` (see PropertySchema), which is how
|
||||
* instance overrides, "Apply to container" and `Set property` work on it for free.
|
||||
*
|
||||
* `frames` cuts the picture into named rectangles (the Texture tab draws them); a texture
|
||||
* with no frames is simply used whole, which is what every single-image texture wants and
|
||||
* what every document written before frames existed already says.
|
||||
*
|
||||
* `width`/`height` are the pixel size as uploaded, kept so a sprite can be sized to its
|
||||
* picture without waiting for the image to load. An empty `file` means the upload failed or
|
||||
* has not happened yet; such a texture draws as nothing.
|
||||
* picture without waiting for the image to load – and so frames can be validated and sliced
|
||||
* before it arrives. An empty `file` means the upload failed or has not happened yet; such a
|
||||
* texture draws as nothing.
|
||||
*/
|
||||
export interface TextureAsset extends AssetBase {
|
||||
type: 'texture';
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frames: TextureFrame[];
|
||||
}
|
||||
|
||||
export type Asset =
|
||||
|
||||
+18
-10
@@ -71,32 +71,40 @@ export type PropertyUsage =
|
||||
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
|
||||
|
||||
/**
|
||||
* Every property that references `assetId`, on assets and on the overrides of placed objects.
|
||||
* Every property that references one of `ids`, on assets and on the overrides of placed
|
||||
* objects.
|
||||
*
|
||||
* This is the other half of "where is this asset used": `findUsages` finds assets that were
|
||||
* *placed*, which is meaningless for a texture – a texture is never placed, it is worn by
|
||||
* sprites through their `texture` property. Deleting one therefore has to count both, or it
|
||||
* reports "0 uses" for a picture that ten sprites are showing.
|
||||
*
|
||||
* A reference is deliberately left dangling rather than cleared when its asset goes: a
|
||||
* Several ids rather than one because a texture is referenced by more than its own id: every
|
||||
* frame cut from it is a reference to the same picture (see `textureRefIds`). Deleting the
|
||||
* texture has to count the sprites wearing a frame of it too, or it under-reports exactly the
|
||||
* sheets that were sliced – the ones with the most users.
|
||||
*
|
||||
* A reference is deliberately left dangling rather than cleared when its target goes: a
|
||||
* dangling one resolves to "no picture" everywhere it is read, and it comes back by itself
|
||||
* when the delete is undone.
|
||||
*/
|
||||
export function findPropertyUsages(project: Project, assetId: string): PropertyUsage[] {
|
||||
if (!assetId) return [];
|
||||
export function findPropertyUsages(project: Project, ids: string | readonly string[]): PropertyUsage[] {
|
||||
const wanted = new Set((typeof ids === 'string' ? [ids] : ids).filter(Boolean));
|
||||
if (wanted.size === 0) return [];
|
||||
const result: PropertyUsage[] = [];
|
||||
const assetKeys = new Map<string, string[]>();
|
||||
const refKeys = new Map<string, string[]>();
|
||||
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
|
||||
for (const asset of project.assets) {
|
||||
const keys = schemaForAsset(asset)
|
||||
.filter((def) => def.kind === 'asset')
|
||||
.filter((def) => def.kind === 'asset' || def.kind === 'texture')
|
||||
.map((def) => def.key);
|
||||
assetKeys.set(asset.id, keys);
|
||||
for (const key of keys) if (asset.props[key] === assetId) result.push({ on: 'asset', asset, key });
|
||||
refKeys.set(asset.id, keys);
|
||||
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
|
||||
}
|
||||
for (const holder of objectHolders(project)) {
|
||||
for (const object of holder.objects) {
|
||||
for (const key of assetKeys.get(object.assetId) ?? [])
|
||||
if (object.overrides[key] === assetId) result.push({ on: 'object', holder, object, key });
|
||||
for (const key of refKeys.get(object.assetId) ?? [])
|
||||
if (matches(object.overrides[key])) result.push({ on: 'object', holder, object, key });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
+205
-6
@@ -1,3 +1,4 @@
|
||||
import type { TextureAsset, TextureFrame } from './Asset';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { findAsset, type Project } from './Project';
|
||||
|
||||
@@ -12,23 +13,221 @@ export function isTextureFile(name: string): boolean {
|
||||
return FILE_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** A picture ready to be drawn: which file, and whether to smooth it when scaled. */
|
||||
/** A rectangle in image pixels: the part of a texture a frame cuts out. */
|
||||
export interface FrameRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Smallest frame that is still a picture rather than a slip of the mouse. */
|
||||
export const MIN_FRAME_SIZE = 1;
|
||||
|
||||
/**
|
||||
* A picture ready to be drawn: which file, whether to smooth it when scaled, and which part
|
||||
* of it. `rect` is null for the whole image – not a rectangle covering everything, because
|
||||
* the renderer can then skip the texture matrix entirely (Pixi fits an unmatrixed fill to the
|
||||
* shape by itself) and because "the whole picture" survives the image being re-uploaded at a
|
||||
* different size.
|
||||
*/
|
||||
export interface TextureRef {
|
||||
file: string;
|
||||
/** False for pixel art, where interpolation is the difference between crisp and mush. */
|
||||
smooth: boolean;
|
||||
/** Sub-rectangle in image pixels, or null for the whole image. */
|
||||
rect: FrameRect | null;
|
||||
/** Size of the whole image in pixels – the reference frame `rect` is expressed in. */
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
}
|
||||
|
||||
/** A texture asset together with one of its frames. */
|
||||
export interface FrameRef {
|
||||
asset: TextureAsset;
|
||||
frame: TextureFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame with that id, wherever in the project it lives.
|
||||
*
|
||||
* Frame ids come from the same generator as asset ids and are unique across the document,
|
||||
* which is what lets a sprite's `texture` property hold either kind: an asset id means the
|
||||
* whole picture, a frame id means one cut-out of it. One property, one picker, and every
|
||||
* sprite written before frames existed keeps meaning exactly what it meant.
|
||||
*/
|
||||
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)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture a bag of properties points at, or null when it points at nothing usable –
|
||||
* no reference, a dangling one (the texture was deleted), or one whose upload never
|
||||
* finished. Callers draw their plain colour in that case, which is what a sprite looked
|
||||
* like before textures existed.
|
||||
* no reference, a dangling one (the texture or the frame was deleted), or one whose upload
|
||||
* never finished. Callers draw their plain colour in that case, which is what a sprite
|
||||
* looked like before textures existed.
|
||||
*/
|
||||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||||
const id = String(props.texture ?? '');
|
||||
if (!id) return null;
|
||||
const asset = findAsset(project, id);
|
||||
if (asset?.type !== 'texture' || !isTextureFile(asset.file)) return null;
|
||||
return { file: asset.file, smooth: String(asset.props.filter ?? 'linear') !== 'nearest' };
|
||||
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: String(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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A rectangle rounded to whole pixels and forced inside the image.
|
||||
*
|
||||
* Frames are pixel coordinates into a bitmap, so a fractional one has no meaning – it would
|
||||
* sample half of a neighbouring pixel along every edge, which on a sprite sheet is the
|
||||
* classic seam of foreign colour. Dragging produces fractions constantly (the view is
|
||||
* zoomed), so this is applied on the way into the model rather than trusted from callers.
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/** How a sheet is cut into a regular grid – the shape of nearly every sprite sheet. */
|
||||
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 (sheets exported with padding). */
|
||||
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 (left to right, top to bottom) –
|
||||
* which is the order sprite sheets are authored in and therefore the order an animation will
|
||||
* want them.
|
||||
*
|
||||
* Cell size is derived from the image rather than asked for: everyone knows how many tiles
|
||||
* their sheet has, almost nobody knows the pixel size off-hand, and deriving it cannot
|
||||
* disagree with the picture. A cell that would reach past the image is dropped instead of
|
||||
* clipped – a clipped one looks like a frame and is not one.
|
||||
*/
|
||||
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
|
||||
const columns = Math.floor(options.columns);
|
||||
const rows = Math.floor(options.rows);
|
||||
// NaN has to be rejected explicitly: every comparison against it is false, so a NaN column
|
||||
// count would pass `columns < 1` and only produce nothing by accident of the loop condition.
|
||||
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;
|
||||
}
|
||||
|
||||
/** The rectangle part of a frame, and the way to write one back – the only glue between the two. */
|
||||
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 frame name that is free within its texture: `base`, else "base 2", "base 3" … – the same
|
||||
* shape `nextAssetName` produces, because a name the user reads should not be numbered by two
|
||||
* conventions in one document.
|
||||
*
|
||||
* Names matter more here than they look: they are what the sprite's picker shows, so two
|
||||
* frames called "idle" would be one choice repeated rather than two.
|
||||
*/
|
||||
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 – what slicing a sheet wants, where the number is the
|
||||
* cell's position and "Frame 1" next to "Frame 2" is the whole point.
|
||||
*
|
||||
* Numbers already taken are skipped rather than suffixed again: re-slicing a sheet that keeps
|
||||
* its old frames must not 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;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,16 @@ export type PropertyDef =
|
||||
* The id is not validated on load – a reference can dangle, exactly like a graph node's
|
||||
* asset parameter, and the places that read one have to cope with that.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] };
|
||||
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
|
||||
/**
|
||||
* A picture: the id of a texture asset (the whole image) or of one of its frames. Its own
|
||||
* kind rather than an `asset` reference with `assetTypes: ['texture']`, because a frame is
|
||||
* not an asset – it is a rectangle inside one, and the picker has to offer both.
|
||||
*
|
||||
* Like an asset reference the id is not validated on load and may dangle; everything that
|
||||
* reads one falls back to drawing no picture.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'texture'; default: string };
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
@@ -69,7 +78,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
* white coin picture becomes ten differently coloured coins, per instance, for free.
|
||||
*/
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||||
{ key: 'texture', label: 'Texture', kind: 'asset', default: '', assetTypes: ['texture'] },
|
||||
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
|
||||
],
|
||||
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property:
|
||||
// only how it is sampled is worth deciding, and that decision is the whole schema.
|
||||
@@ -190,11 +199,12 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
}
|
||||
case 'boolean':
|
||||
return Boolean(value);
|
||||
// An asset reference is an id like the other two are plain strings; whether it still
|
||||
// resolves is not decidable from a single property definition.
|
||||
// An asset or texture reference is an id like the other two are plain strings; whether it
|
||||
// still resolves is not decidable from a single property definition.
|
||||
case 'text':
|
||||
case 'key':
|
||||
case 'asset':
|
||||
case 'texture':
|
||||
return String(value);
|
||||
case 'enum': {
|
||||
const s = String(value);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TextureAsset, TextureFrame } from '../model/Asset';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { findFrame } from '../model/texture';
|
||||
|
||||
/** Shown for a `texture` property that references nothing. */
|
||||
export const NO_TEXTURE_LABEL = 'None';
|
||||
|
||||
/** Separates a texture's name from a frame's name wherever the two are shown together. */
|
||||
const FRAME_SEPARATOR = ' / ';
|
||||
|
||||
/** One thing a sprite's `texture` property can be set to. */
|
||||
export interface TextureChoice {
|
||||
/** Asset id for the whole picture, frame id for one cut-out of it. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** The texture this choice belongs to – frames of one sheet stay together in the menu. */
|
||||
asset: TextureAsset;
|
||||
/** Null for the whole-picture entry. */
|
||||
frame: TextureFrame | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a texture reference reads wherever it is displayed: the texture's name for a whole
|
||||
* picture, "Sheet / Frame" for a frame, "None" for the empty id, and `<id>?` when the id no
|
||||
* longer resolves.
|
||||
*
|
||||
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
|
||||
* only trace left of what was meant, and blanking it would make a deleted frame look like a
|
||||
* field that was never filled in. `project` may be null while a view has no project yet.
|
||||
*/
|
||||
export function textureRefLabel(project: Project | null, id: string): string {
|
||||
if (id === '') return NO_TEXTURE_LABEL;
|
||||
if (!project) return `${id}?`;
|
||||
const asset = findAsset(project, id);
|
||||
if (asset) return asset.type === 'texture' ? asset.name : `${id}?`;
|
||||
const found = findFrame(project, id);
|
||||
return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a sprite may wear, in menu order: each texture followed by its own frames.
|
||||
*
|
||||
* The whole picture stays on offer next to the frames rather than being replaced by them.
|
||||
* A texture that was never sliced has no frames at all, and a sheet's full image is
|
||||
* occasionally what you want (a background, a check of the sheet itself) – forcing a frame
|
||||
* would mean slicing every single-image texture into one pointless frame.
|
||||
*
|
||||
* Textures are sorted by name so the order does not depend on upload time; frames keep the
|
||||
* order they were created in, which for a sliced sheet is reading order and therefore the
|
||||
* order the animation runs in.
|
||||
*/
|
||||
export function textureRefChoices(project: Project): TextureChoice[] {
|
||||
const textures = project.assets
|
||||
.filter((a): a is TextureAsset => a.type === 'texture')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const choices: TextureChoice[] = [];
|
||||
for (const asset of textures) {
|
||||
choices.push({ id: asset.id, label: asset.name, asset, frame: null });
|
||||
for (const frame of asset.frames)
|
||||
choices.push({
|
||||
id: frame.id,
|
||||
label: `${asset.name}${FRAME_SEPARATOR}${frame.name}`,
|
||||
asset,
|
||||
frame,
|
||||
});
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// The graph package index registers the built-in nodes; unknown node types are dropped below.
|
||||
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import {
|
||||
coerceValue,
|
||||
@@ -43,8 +43,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: build assets.
|
||||
// Pass 2: build assets. Frame ids live in the same reference space as asset ids (a sprite's
|
||||
// `texture` may name either), so they are handed the ids already spoken for.
|
||||
const assets: Asset[] = [];
|
||||
const takenIds = new Set(schemas.keys());
|
||||
for (const a of rawAssets) {
|
||||
const id = typeof a.id === 'string' ? a.id : '';
|
||||
const schema = schemas.get(id);
|
||||
@@ -64,7 +66,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a) });
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
} else {
|
||||
assets.push({ id, type, name, props });
|
||||
}
|
||||
@@ -189,11 +191,31 @@ function normalizeGraph(raw: unknown): Graph {
|
||||
* is dropped rather than kept: it can only have been hand-written, and a request built from it
|
||||
* would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour.
|
||||
*/
|
||||
function normalizeTexture(raw: Record<string, unknown>): { file: string; width: number; height: number } {
|
||||
function normalizeTexture(
|
||||
raw: Record<string, unknown>,
|
||||
takenIds: Set<string>,
|
||||
): { file: string; width: number; height: number; frames: TextureFrame[] } {
|
||||
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
|
||||
const size = (value: unknown): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||||
return { file, width: size(raw.width), height: size(raw.height) };
|
||||
const width = size(raw.width);
|
||||
const height = size(raw.height);
|
||||
const frames: TextureFrame[] = [];
|
||||
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
|
||||
const f = asRecord(item);
|
||||
const id = typeof f.id === 'string' ? f.id : '';
|
||||
// A frame id shares the reference space with asset ids (a sprite may name either), so a
|
||||
// duplicate would make one reference mean two pictures. Drop it rather than resolve it.
|
||||
if (!id || takenIds.has(id)) continue;
|
||||
takenIds.add(id);
|
||||
const rect = normalizeFrameRect(
|
||||
{ x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) },
|
||||
width,
|
||||
height,
|
||||
);
|
||||
frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect });
|
||||
}
|
||||
return { file, width, height, frames };
|
||||
}
|
||||
|
||||
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||||
|
||||
Reference in new issue
Block a user