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:
rootandClaude Opus 5 committed 2026-09-08 14:27:23 +02:00
1 parent 5a6af74e44
commit 35a1a23af8
29 files changed
+2649 -52

No files matched your search

+33 -3
View File
@@ -189,6 +189,35 @@ picture becomes ten differently coloured coins, per instance, for nothing. Witho
**`filter`** is the texture's only property: *Smooth* (default) or *Pixel art*, which is the
difference between crisp and mush when a small image is scaled up.
### Frames — cutting a sheet up
The **Texture tab** shows one texture with its **frames** on top: named rectangles in image
pixels. Drag on empty picture to cut one, drag it or its eight handles to adjust it, and edit
the exact numbers in the inspector; `Delete` removes the selected frame. **Slice…** cuts a
regular grid at once — you give columns and rows (not a cell size: everyone knows how many
tiles their sheet has, and deriving the cell size from the picture cannot disagree with it),
with optional offset and spacing for padded sheets, and the dialog says how many frames of
what size the numbers would produce before it commits.
A sprite then wears **a frame instead of the whole picture** — but through the *same*
property. `texture` holds either a texture id (the whole image) or a frame id, because frame
ids come from the same generator and are unique across the document. That is what keeps one
picker, one property and one set of instance overrides, and what makes every sprite written
before frames existed still mean exactly what it meant. The picker lists each sheet followed
by its frames as "Sheet / Frame", and it is searchable, because one sliced sheet adds an entry
per cell.
Frames are intrinsic data on the texture, like a scene's objects and a behavior's graph — not
properties, since nothing about them is overridable per instance. All edits go through
commands, so cutting, dragging, slicing and re-slicing are undoable, and one drag is **one**
undo step. Deleting a frame leaves the sprites pointing at it: a dangling reference draws the
plain colour and comes back by itself on undo. Deleting the whole texture counts the sprites
wearing *any* of its frames, not just the ones naming the texture directly.
Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill
ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is
worth having: a sheet stays **one** GPU upload however many frames of it are on screen.
**Nothing ever breaks on a missing picture.** While an image loads, and for a reference that
dangles because the texture was deleted, the sprite draws its plain colour — exactly what it
looked like before textures existed. So there is no placeholder, no flicker and no error path.
@@ -347,6 +376,7 @@ object in the selection (hierarchy rows too).
Editing a container's template directly (today changes reach it only via **Apply to
container** from an instance), reordering the hierarchy, several scenes in one run (a
`Load scene` node – screens are containers today), sound, auth, touch-optimised layout,
graph comments/groups. Textures exist but are always stretched to the sprite's `width` ×
`height`: no sprite sheets, no animation, no nine-slice, and no "size from texture" button.
Unreferenced image files are never collected.
graph comments/groups. A frame is always stretched to the sprite's `width` × `height`: no
animation (nothing steps through a sheet's frames yet), no nine-slice, and no "size from
frame" button. Slicing cuts a regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected.
+11 -2
View File
@@ -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}"`;
}
+217
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+14 -4
View File
@@ -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);
+68
View File
@@ -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;
}
+28 -6
View File
@@ -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 {
+25 -6
View File
@@ -14,9 +14,13 @@ import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
import { ProjectSession } from './ProjectSession';
import { installShortcuts } from './shortcuts';
import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
export type CentreTab = 'scene' | 'graph' | 'game';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
/** Tab strip order – also the one list `showTab` walks, so a new tab cannot be half-added. */
const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
@@ -34,6 +38,8 @@ export class Editor implements PlayHost {
/** Game tab: the running world through its camera. */
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
/** Texture tab: one sheet with its frames. */
readonly textureEditor: TextureEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
@@ -63,6 +69,7 @@ export class Editor implements PlayHost {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.textureEditor = new TextureEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
@@ -89,7 +96,7 @@ export class Editor implements PlayHost {
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor.
// Centre: a tab strip above the scene view, the game view, the graph editor or the sheet editor.
this.centre = new VStack();
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
tabs.minHeight = Theme.controlHeight + 8;
@@ -97,14 +104,21 @@ export class Editor implements PlayHost {
scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }),
game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }),
graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }),
texture: new Button('Texture', {
variant: 'ghost',
width: 90,
onClick: () => this.showTab('texture'),
}),
};
for (const tab of ['scene', 'game', 'graph'] as const) tabs.add(this.tabButtons[tab]);
for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]);
this.centre.add(tabs);
this.centre.add(this.viewport, 1);
this.centre.add(this.graphEditor, 1);
this.centre.add(this.gameView, 1);
this.centre.add(this.textureEditor, 1);
this.graphEditor.visible = false;
this.gameView.visible = false;
this.textureEditor.visible = false;
this.tabButtons.scene.setToggled(true);
this.leftColumn.minWidth = this.leftWidth;
@@ -130,9 +144,13 @@ export class Editor implements PlayHost {
attachAutosave(state, store);
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// Selecting a graph node switches to the graph; selecting a behavior does not force it.
// Selecting a graph node switches to the graph, selecting a frame to the sheet: both are
// things that exist *only* in their tab, so a selection you could not see would be a lie.
// Selecting the behavior or the texture asset itself does not force anything.
state.on('selection', () => {
if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
const kind = state.selection?.kind;
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
});
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
state.on('project', () => this.play.syncFromModel());
@@ -143,7 +161,8 @@ export class Editor implements PlayHost {
this.viewport.visible = tab === 'scene';
this.graphEditor.visible = tab === 'graph';
this.gameView.visible = tab === 'game';
for (const [key, button] of Object.entries(this.tabButtons)) button.setToggled(key === tab);
this.textureEditor.visible = tab === 'texture';
for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab);
this.centre.relayout();
ui().focus.validate();
}
+5 -2
View File
@@ -7,6 +7,7 @@ import { AddColliderCommand } from '../core/commands/colliderCommands';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { findAsset, findPropertyUsages, findUsages } from '../core/model/Project';
import { textureRefIds } from '../core/model/texture';
import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
@@ -41,8 +42,10 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
if (!asset) return;
const usages = findUsages(state.project, assetId).length;
// A texture is never placed – it is worn through a property – so counting placed instances
// would report "unused" for a picture a dozen sprites are showing.
const references = findPropertyUsages(state.project, assetId).length;
// would report "unused" for a picture a dozen sprites are showing. Its frames count as the
// texture itself: a sprite wearing one of them loses its picture just the same.
const ids = asset.type === 'texture' ? textureRefIds(asset) : assetId;
const references = findPropertyUsages(state.project, ids).length;
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
+12 -3
View File
@@ -1,6 +1,6 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
import { ui, Widget } from '../ui';
import type { Editor } from './Editor';
import type { CentreTab, Editor } from './Editor';
import type { EditorState } from './state';
/**
@@ -8,7 +8,7 @@ import type { EditorState } from './state';
* visible text and to read editor state. Read-only access; harmless in production.
*/
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
const { viewport, gameView, graphEditor, hierarchy } = editor;
const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -81,6 +81,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
center: () => centreOf(graphEditor),
},
texture: {
current: () => textureEditor.currentAssetId,
/**
* Where an image pixel of the shown sheet sits on screen. Frames are dragged in image
* coordinates, so without this a check could only drag somewhere and hope – it could not
* aim at a known part of the picture and then assert which part came out.
*/
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
@@ -103,6 +112,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
},
fps: () => editor.fps,
tab: () => editor.tab,
showTab: (tab: 'scene' | 'graph' | 'game') => editor.showTab(tab),
showTab: (tab: CentreTab) => editor.showTab(tab),
};
}
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Container } from 'pixi.js';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import {
@@ -13,7 +14,8 @@ import {
/**
* Shows the properties of the current selection. The rows are generated from the
* PropertySchema by one section per selection kind (asset, scene object, graph node); the
* PropertySchema by one section per selection kind (asset, scene object, graph node, texture
* frame); the
* panel only decides when a section has to be rebuilt and when its values merely refresh.
*/
export class Inspector extends Panel {
@@ -70,6 +72,7 @@ export class Inspector extends Panel {
if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' }));
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds);
else buildObjectSection(this.host, s.sceneId, s.objectId);
this.scroll.setContent(this.host.body);
@@ -80,6 +83,7 @@ export class Inspector extends Panel {
if (!s) return '';
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId);
}
@@ -96,6 +100,8 @@ export class Inspector extends Panel {
? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node'
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
if (!ok) this.rebuild();
}
+31
View File
@@ -7,6 +7,7 @@ import {
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
Checkbox,
@@ -228,6 +229,36 @@ export function createField(
},
};
}
case 'texture': {
// The id is kept here for the same reason as in the asset case, and more urgently: a
// frame's label repeats its sheet's name, so several entries can read identically.
let current = String(value);
const button = new Dropdown(textureRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') },
// Built on click, not once per row: textures are uploaded and frames are cut, renamed
// and sliced away while the inspector stands, and the picker has to show what exists now.
...textureRefChoices(ctx.project()).map((choice) => ({
label: mark(choice.id, choice.label),
onSelect: () => handlers.onChange(choice.id),
})),
];
// Searchable: one sliced sheet alone contributes an entry per frame. The entries read
// "Sheet / Frame", so the sheet's own name gathers its frames into one block of the
// list and typing either name finds them.
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = String(v);
button.setText(textureRefLabel(ctx.project(), current));
},
};
}
}
}
+136
View File
@@ -0,0 +1,136 @@
import {
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
} from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
import { Button, TextField } from '../../ui';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
/** The components of the rectangle, in the order they are shown. */
const RECT_COMPONENTS = [
{ key: 'x', label: 'X' },
{ key: 'y', label: 'Y' },
{ key: 'w', label: 'Width' },
{ key: 'h', label: 'Height' },
] as const;
/**
* Structure-defining facts of a frame selection: none. A frame is a name and four numbers
* whatever texture it was cut from, so the rows never differ – only the frame disappearing
* can force a rebuild, and that is what `refreshFrameSection` reports.
*/
export function frameSignature(project: Project, assetId: string, frameId: string): string {
return currentFrame(project, assetId, frameId) ? 'frame' : 'missing';
}
/** Name, the rectangle it cuts out of its texture, and the way to delete it. */
export function buildFrameSection(host: InspectorHost, assetId: string, frameId: string): void {
const asset = currentTexture(host.state.project, assetId);
const frame = asset?.frames.find((f) => f.id === frameId);
if (!asset || !frame) return;
addTitle(host, 'TEXTURE FRAME');
host.nameField = new TextField(frame.name, {
onChange: (name) => {
// Like an asset name. The command rejects a blank name by itself; what it cannot do is
// put the old text back into the field, which is why an empty one is not sent at all.
if (name.trim()) host.state.execute(new RenameFrameCommand(assetId, frameId, name.trim()));
else host.nameField?.setValue(frame.name);
},
});
const { row } = labelledRow('Name');
row.add(host.nameField, 1);
host.body.add(row);
addNote(host, `Frame of the texture "${asset.name}".`);
addTitle(host, 'RECTANGLE (IMAGE PIXELS)');
for (const component of RECT_COMPONENTS)
rectRow(host, assetId, frameId, component.key, component.label, frame[component.key]);
addGap(host);
host.body.add(
new Button('Delete frame', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveFrameCommand(assetId, frameId)),
}),
);
}
/** Pushes the frame's current name and rectangle into the rows. False when it is gone (rebuild). */
export function refreshFrameSection(host: InspectorHost, assetId: string, frameId: string): boolean {
const frame = currentFrame(host.state.project, assetId, frameId);
if (!frame) return false;
host.nameField?.setValue(frame.name);
for (const component of RECT_COMPONENTS) host.fields.get(component.key)?.setValue(frame[component.key]);
return true;
}
/** A number row editing one component of the rectangle. Whole pixels: a frame indexes a bitmap. */
function rectRow(
host: InspectorHost,
assetId: string,
frameId: string,
key: keyof FrameRect,
label: string,
value: number,
): void {
const row = createFieldRow(
host,
{ key, label, kind: 'number', default: 0, step: 1 },
value,
rectHandlers(host, assetId, frameId, key),
);
host.fields.set(key, row);
host.body.add(row.row);
}
/**
* The whole rectangle with one component replaced; the other three are read from the model at
* that moment, so a frame dragged in the Texture tab in between is not undone by typing into a
* field beside it.
*
* Normalising here as well as in the command is not a duplicate rule but the same one applied
* to the *preview*: an un-normalised preview would draw the frame off the sheet on the way
* through a scrub, and a half-typed "1" of "16" would jump it to the corner. Because it is the
* same function the command uses, what is previewed is exactly what is committed.
*/
function rectHandlers(
host: InspectorHost,
assetId: string,
frameId: string,
key: keyof FrameRect,
): FieldHandlers {
return previewingHandlers<TextureFrame, FrameRect>(host, {
target: () => currentFrame(host.state.project, assetId, frameId),
read: frameRect,
write: setFrameRect,
toModel: (input, frame) => {
const asset = currentTexture(host.state.project, assetId);
return normalizeFrameRect(
{ ...frameRect(frame), [key]: Number(input) },
asset?.width ?? 0,
asset?.height ?? 0,
);
},
commit: (rect) => new SetFrameRectCommand(assetId, frameId, rect),
});
}
/**
* The frame is looked up inside the selected texture rather than by its id alone: the
* selection names both, and honouring both keeps a selection left over from a deleted texture
* from editing whichever frame later inherits the id.
*/
function currentFrame(project: Project, assetId: string, frameId: string): TextureFrame | undefined {
return currentTexture(project, assetId)?.frames.find((f) => f.id === frameId);
}
function currentTexture(project: Project, assetId: string): TextureAsset | undefined {
const asset = findAsset(project, assetId);
return asset?.type === 'texture' ? asset : undefined;
}
+2
View File
@@ -1,3 +1,4 @@
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { ui } from '../ui';
import { confirmDeleteAsset, createContainer, removeObjects } from './actions';
@@ -38,6 +39,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
const s = state.selection;
if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
return s !== null;
}
+22
View File
@@ -14,6 +14,8 @@ export type Selection =
| { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
| null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */
@@ -235,6 +237,22 @@ export class EditorState {
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'frame') {
const asset = findAsset(this.project, s.assetId);
// Same fallback as a graph node: the texture stays open when its frame vanished.
if (!asset || asset.type !== 'texture') this.select(null);
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
}
/** The texture asset the current selection refers to (the asset itself or one of its frames). */
get selectedTextureId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind !== 'asset' && s.kind !== 'frame') return null;
const asset = findAsset(this.project, s.assetId);
return asset && asset.type === 'texture' ? asset.id : null;
}
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
@@ -242,6 +260,9 @@ export class EditorState {
const s = this.selection;
if (!s) return null;
if (s.kind === 'node') return s.assetId;
// A frame names a texture and can never lead to a behavior. Answered here rather than
// fallen through, so what remains below is the asset/object pair the lookup assumes.
if (s.kind === 'frame') return null;
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
const asset = assetId ? findAsset(this.project, assetId) : undefined;
@@ -276,5 +297,6 @@ export function sameSelection(a: Selection, b: Selection): boolean {
);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
return false;
}
+115
View File
@@ -0,0 +1,115 @@
import type { TextureAsset } from '../../core/model/Asset';
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
/** Width of the label column, matching the inspector's so the two read as one language. */
const LABEL_WIDTH = 76;
const FIELD_WIDTH = 76;
/**
* Cuts a sheet into a grid. Asks for the number of columns and rows rather than a cell size:
* everyone knows how many tiles their sheet has, and deriving the cell size from the picture
* cannot disagree with it.
*
* The dialog states, live, how many frames the current numbers would produce and how big each
* one is. That readout is the whole point – it turns "did I mean 8×4 or 4×8" from something
* you find out by undoing into something you see before committing.
*/
export class SliceDialog extends Popup {
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
private replace = true;
private readonly summary: Label;
private readonly confirm: Button;
constructor(
private readonly asset: TextureAsset,
onSlice: (options: SliceOptions, replace: boolean) => void,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 340;
this.content.add(new Label(`Slice "${asset.name}" into a grid`, { style: 'bodyBold' }));
this.content.add(new Label(`Picture is ${asset.width} × ${asset.height} px`, { style: 'muted' }));
this.content.add(this.pair('Columns', 'columns', 1, 'Rows', 'rows', 1));
this.content.add(this.pair('Offset X', 'offsetX', 0, 'Offset Y', 'offsetY', 0));
this.content.add(this.pair('Spacing X', 'spacingX', 0, 'Spacing Y', 'spacingY', 0));
const replaceRow = new HStack({ gap: Theme.gap, align: 'center' });
replaceRow.minHeight = Theme.rowHeight;
const box = new Checkbox(this.replace);
box.onChange = (value) => {
this.replace = Boolean(value);
};
replaceRow.add(box);
replaceRow.add(new Label('Replace the frames this texture already has', { style: 'muted' }));
this.content.add(replaceRow);
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.confirm = new Button('Slice', { variant: 'primary' });
this.confirm.onClick = this.closeThen(() => onSlice({ ...this.options }, this.replace));
buttons.add(this.confirm);
this.content.add(buttons);
this.refreshSummary();
}
/** Two numeric fields on one row – the grid's parameters come in x/y pairs. */
private pair(
labelA: string,
keyA: keyof SliceOptions,
minA: number,
labelB: string,
keyB: keyof SliceOptions,
minB: number,
): Widget {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.labelFor(labelA));
row.add(this.fieldFor(keyA, minA));
row.add(new Spacer(Theme.padding));
row.add(this.labelFor(labelB));
row.add(this.fieldFor(keyB, minB));
row.add(new Spacer(), 1);
return row;
}
private labelFor(text: string): Label {
const label = new Label(text, { style: 'muted' });
label.minWidth = LABEL_WIDTH;
return label;
}
private fieldFor(key: keyof SliceOptions, min: number): NumberField {
const field = new NumberField(this.options[key], {
min,
step: 1,
onChange: (value) => {
this.options[key] = Math.round(Number(value));
this.refreshSummary();
},
});
field.minWidth = FIELD_WIDTH;
return field;
}
/** Live readout of what the current numbers would do, including the refusal case. */
private refreshSummary(): void {
const rects = sliceFrames(this.asset.width, this.asset.height, this.options);
const first = rects[0];
this.summary.setText(
first
? `${rects.length} frames of ${first.w} × ${first.h} px`
: 'These numbers do not fit the picture – no frames.',
);
this.summary.setStyle(first ? 'muted' : 'body');
this.confirm.setEnabled(rects.length > 0);
this.refreshLayout();
}
}
+498
View File
@@ -0,0 +1,498 @@
import {
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
Sprite,
Text,
} from 'pixi.js';
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import {
dragFrame,
fitView,
type FrameHandle,
frameHandles,
handleAt,
handleCursor,
handlePoint,
imageToLocal,
localToImage,
rectBetween,
type SheetView,
zoomAbout,
} from './sheetView';
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
const HANDLE_SIZE = 8;
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/** Side of one square of the transparency checkerboard, in screen pixels. */
const CHECKER_SIZE = 8;
const CHECKER_DARK = 0x1a1c21;
const CHECKER_LIGHT = 0x24272e;
/** Below this on-screen frame size the name is illegible clutter and is left out. */
const MIN_LABEL_SIZE = 28;
/** Inset of a frame's name plate from the frame's top-left corner, and its own padding. */
const LABEL_INSET = 3;
const LABEL_PAD = 2;
/** A drag of an existing frame: which one, from what, and by which handle. */
interface FrameDrag {
frameId: string;
handle: FrameHandle;
start: FrameRect;
pointer: Vec2;
moved: boolean;
}
/** A new frame being spanned on empty space (image pixels; nothing is in the model yet). */
interface NewFrame {
start: Vec2;
current: Vec2;
active: boolean;
}
/**
* The Texture tab's canvas: one texture's picture with its frames drawn on top, where frames
* are created by dragging on empty space and moved or resized by their handles.
*
* Everything but the picture is drawn in widget-local pixels rather than in a zoomed
* container: outlines, handles and names have to keep a constant size on screen, which a
* scaled container would defeat. The picture itself is a Sprite whose scale is the zoom, so
* the GPU does the sampling – and `scaleMode` follows the texture's own Filter property, so a
* pixel-art sheet is inspected as crisp pixels rather than as the mush a smoothed preview
* would show.
*/
export class TextureCanvas extends Widget {
private readonly bg = new Graphics();
private readonly checker = new Graphics();
private readonly imageLayer = new Container();
private readonly image = new Sprite();
private readonly overlay = new Graphics();
private readonly labels = new Map<string, Text>();
private readonly labelLayer = new Container();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
private assetId = '';
/** Fit the picture once the widget has a real size and a picture to fit (it starts hidden). */
private needsFit = true;
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null;
constructor(private readonly state: EditorState) {
super();
this.imageLayer.addChild(this.image);
this.addChild(
this.bg,
this.checker,
this.imageLayer,
this.overlay,
this.labelLayer,
this.hint,
this.clip,
);
this.imageLayer.mask = this.clip;
this.imageLayer.eventMode = 'none';
this.overlay.eventMode = 'none';
this.labelLayer.eventMode = 'none';
this.hint.eventMode = 'none';
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
state.on('selection', () => this.redraw());
state.on('load', () => {
this.needsFit = true;
this.redraw();
});
// A picture arrives asynchronously; until then there is nothing to fit the view to.
textureLibrary.onChange(() => this.redraw());
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
/** Which texture is shown. Switching resets the view, since the next picture is a new subject. */
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.needsFit = true;
this.redraw();
}
get currentAssetId(): string {
return this.assetId;
}
/** Refits the picture into the widget – the "reset view" the toolbar offers. */
fit(): void {
this.needsFit = true;
this.redraw();
}
/**
* Where an image pixel currently sits on screen. Only the check scripts need this: dragging
* a frame is done in image coordinates, so a test that cannot convert them can drag
* somewhere but cannot aim at a known part of the picture.
*/
imageToScreen(x: number, y: number): Vec2 {
const local = imageToLocal(this.view, x, y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
// --- model ------------------------------------------------------------------------------
private asset(): TextureAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'texture' ? asset : null;
}
private selectedFrameId(): string {
const s = this.state.selection;
return s?.kind === 'frame' && s.assetId === this.assetId ? s.frameId : '';
}
private frame(frameId: string): TextureFrame | undefined {
return this.asset()?.frames.find((f) => f.id === frameId);
}
// --- pointer ----------------------------------------------------------------------------
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
// Nothing is cut from a picture whose size is unknown: `normalizeFrameRect` has no bounds
// to hold such a frame inside, so it would keep a rectangle that lies off the sheet for
// good. An unmeasured texture is a failed upload, and the hint says so.
if (!asset || asset.width <= 0 || asset.height <= 0) return;
const local = this.toLocal(e.global);
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation();
return;
}
if (e.button !== 0) return;
ui().focus.focus(this);
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
// The selected frame is tested first and by its handles: it is the one being worked on,
// and its handles must stay reachable even where a frame drawn later overlaps them.
const selected = this.frame(this.selectedFrameId());
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
if (selected && onSelected) {
this.startDrag(selected, onSelected, local);
e.stopPropagation();
return;
}
// Last first: later frames are drawn on top, so they are what a click on an overlap means.
for (let i = asset.frames.length - 1; i >= 0; i--) {
const frame = asset.frames[i]!;
if (handleAt(frame, point, tolerance) === null) continue;
this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id });
this.startDrag(frame, 'move', local);
e.stopPropagation();
return;
}
this.spanning = { start: point, current: point, active: false };
e.stopPropagation();
}
private startDrag(frame: TextureFrame, handle: FrameHandle, local: Vec2): void {
this.dragging = {
frameId: frame.id,
handle,
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
pointer: { x: local.x, y: local.y },
moved: false,
};
}
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.panning) {
this.view = {
...this.view,
panX: this.panning.panX + (local.x - this.panning.pointer.x),
panY: this.panning.panY + (local.y - this.panning.pointer.y),
};
this.redraw();
return;
}
if (this.dragging) {
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.view.zoom < DRAG_THRESHOLD) return;
this.dragging.moved = true;
this.previewDrag(dx, dy);
return;
}
if (this.spanning) {
this.spanning.current = localToImage(this.view, local.x, local.y);
const size = rectBetween(this.spanning.start, this.spanning.current);
if (!this.spanning.active && Math.max(size.w, size.h) * this.view.zoom < DRAG_THRESHOLD) return;
this.spanning.active = true;
this.redraw();
return;
}
this.updateCursor(local);
}
/** Applies a drag to the model directly, as a preview: no undo entry until the pointer is up. */
private previewDrag(dx: number, dy: number): void {
const drag = this.dragging;
const asset = this.asset();
const frame = drag && this.frame(drag.frameId);
if (!drag || !asset || !frame) return;
Object.assign(
frame,
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
);
this.state.preview();
}
private onPointerUp(): void {
this.panning = null;
const drag = this.dragging;
const span = this.spanning;
this.dragging = null;
this.spanning = null;
if (drag) {
const asset = this.asset();
const frame = this.frame(drag.frameId);
if (drag.moved && asset && frame) {
// The preview moved it already; put it back so the command records the real start.
const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
Object.assign(frame, drag.start);
this.state.execute(new SetFrameRectCommand(asset.id, drag.frameId, next));
}
return;
}
if (!span) return;
if (!span.active) {
// A click on empty space clears the selection, like everywhere else in the editor.
this.state.select({ kind: 'asset', assetId: this.assetId });
this.redraw();
return;
}
const asset = this.asset();
if (!asset) return;
const rect = normalizeFrameRect(rectBetween(span.start, span.current), asset.width, asset.height);
if (rect.w < MIN_FRAME_SIZE || rect.h < MIN_FRAME_SIZE) return;
const command = new AddFrameCommand(asset.id, rect);
if (this.state.execute(command))
this.state.select({ kind: 'frame', assetId: asset.id, frameId: command.frameId });
this.redraw();
}
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
this.needsFit = false;
this.redraw();
e.preventDefault();
}
/** Announces what a press would do before it happens (resize arrows, move, crosshair). */
private updateCursor(local: Vec2): void {
const asset = this.asset();
if (!asset) {
this.cursor = 'default';
return;
}
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
const selected = this.frame(this.selectedFrameId());
const handle = selected ? handleAt(selected, point, tolerance) : null;
if (handle) {
this.cursor = handleCursor(handle);
return;
}
const over = asset.frames.some((f) => handleAt(f, point, tolerance) !== null);
this.cursor = over ? 'move' : 'crosshair';
}
// --- drawing ----------------------------------------------------------------------------
override redraw(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
const asset = this.asset();
const texture = asset && asset.file ? this.pictureOf(asset) : null;
if (asset && texture && this.needsFit && this.w > 0 && this.h > 0) {
this.view = fitView(asset.width, asset.height, this.w, this.h);
this.needsFit = false;
}
this.image.visible = Boolean(texture);
if (asset && texture) {
this.image.texture = texture;
this.image.position.set(this.view.panX, this.view.panY);
// The sprite is drawn at the picture's own pixel size, so the zoom is the whole scale.
this.image.width = asset.width * this.view.zoom;
this.image.height = asset.height * this.view.zoom;
}
this.drawChecker(asset, Boolean(texture));
this.drawFrames(asset);
this.syncHint(asset, Boolean(texture));
}
private pictureOf(asset: TextureAsset) {
return textureLibrary.get({
file: asset.file,
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
rect: null,
sourceWidth: asset.width,
sourceHeight: asset.height,
});
}
/**
* The checkerboard behind the picture. It is clipped to the picture rather than filling the
* widget: its job is to make transparent pixels of *this image* visible, and a board that
* ran past the edges would hide where the image actually ends.
*/
private drawChecker(asset: TextureAsset | null, hasPicture: boolean): void {
this.checker.clear();
if (!asset || !hasPicture || asset.width <= 0) return;
const origin = imageToLocal(this.view, 0, 0);
const w = asset.width * this.view.zoom;
const h = asset.height * this.view.zoom;
this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK);
// Only the squares that land inside the widget are worth emitting; a 4000 px sheet at a
// high zoom would otherwise cost tens of thousands of rectangles per redraw.
const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE));
const x0 = from(origin.x, 0);
const y0 = from(origin.y, 0);
const x1 = Math.min(Math.ceil(w / CHECKER_SIZE), from(origin.x, this.w) + 1);
const y1 = Math.min(Math.ceil(h / CHECKER_SIZE), from(origin.y, this.h) + 1);
for (let cy = y0; cy < y1; cy++) {
for (let cx = x0; cx < x1; cx++) {
if ((cx + cy) % 2 === 0) continue;
const px = origin.x + cx * CHECKER_SIZE;
const py = origin.y + cy * CHECKER_SIZE;
this.checker.rect(
px,
py,
Math.min(CHECKER_SIZE, w - cx * CHECKER_SIZE),
Math.min(CHECKER_SIZE, h - cy * CHECKER_SIZE),
);
}
}
this.checker.fill(CHECKER_LIGHT);
}
private drawFrames(asset: TextureAsset | null): void {
this.overlay.clear();
const alive = new Set<string>();
if (asset) {
const selectedId = this.selectedFrameId();
for (const frame of asset.frames) {
alive.add(frame.id);
this.drawFrame(frame, frame.id === selectedId);
}
const selected = this.frame(selectedId);
if (selected) this.drawHandles(selected);
}
if (this.spanning?.active) {
const rect = rectBetween(this.spanning.start, this.spanning.current);
const a = imageToLocal(this.view, rect.x, rect.y);
this.overlay
.rect(a.x, a.y, rect.w * this.view.zoom, rect.h * this.view.zoom)
.fill({ color: Theme.colors.marquee, alpha: 0.15 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
for (const [id, label] of this.labels) {
if (alive.has(id)) continue;
label.destroy();
this.labels.delete(id);
}
}
private drawFrame(frame: TextureFrame, selected: boolean): void {
const a = imageToLocal(this.view, frame.x, frame.y);
const w = frame.w * this.view.zoom;
const h = frame.h * this.view.zoom;
const color = selected ? Theme.colors.accent : Theme.colors.collider;
this.overlay.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color });
this.syncLabel(frame, a, w, h, selected);
}
private drawHandles(frame: TextureFrame): void {
const half = HANDLE_SIZE / 2;
for (const handle of frameHandles()) {
const p = handlePoint(frame, handle);
const local = imageToLocal(this.view, p.x, p.y);
this.overlay
.rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE)
.fill(Theme.colors.accentText)
.stroke({ width: 1, color: Theme.colors.accent });
}
}
private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void {
let label = this.labels.get(frame.id);
// A name that does not fit inside its frame would be read as belonging to the neighbour.
const show = Math.min(w, h) >= MIN_LABEL_SIZE && at.x < this.w && at.y < this.h && at.x + w > 0;
if (!show) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ style: textStyle('body') });
this.labels.set(frame.id, label);
this.labelLayer.addChild(label);
}
label.visible = true;
if (label.text !== frame.name) label.text = frame.name;
label.style.fill = Theme.colors.text;
const x = at.x + LABEL_INSET;
const y = at.y + LABEL_INSET;
label.position.set(x + LABEL_PAD, y + LABEL_PAD);
/*
* A dark plate behind the name, not just a colour choice for the text.
*
* The name is drawn *on the user's picture*, which may be any colour – the grey used at
* first vanished on the light quadrants of a test sheet, and no fixed colour can be safe on
* an arbitrary image. The plate makes the background known, so light text on it always
* reads. Same reasoning as the scene grid, which is drawn white at a low alpha rather than
* in a theme colour so that it survives whatever the scene puts behind it.
*/
this.overlay
.roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2)
.fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 });
}
private syncHint(asset: TextureAsset | null, hasPicture: boolean): void {
const text = !asset
? 'No texture selected – pick one in the Assets panel or import an image.'
: !asset.file
? 'This texture has no picture yet.'
: !hasPicture
? 'Loading…'
: asset.frames.length === 0
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
: '';
this.hint.text = text;
this.hint.visible = text !== '';
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { Graphics } from 'pixi.js';
import { SliceFramesCommand } from '../../core/commands/frameCommands';
import type { TextureAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
import type { EditorState } from '../state';
import { SliceDialog } from './SliceDialog';
import { TextureCanvas } from './TextureCanvas';
/** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */
const INFO_WIDTH = 190;
/**
* The Texture tab: a picker for which texture is being worked on, the tools that act on the
* whole sheet, and the canvas the frames are drawn on.
*
* It follows the selection the way the graph editor does – selecting a texture asset (or one
* of its frames) shows it here – but it does not *force* the tab, and it keeps showing the
* last texture when the selection moves elsewhere. Otherwise clicking a sprite to check
* whether it wears the right frame would empty the tab you are working in.
*/
export class TextureEditor extends VStack {
private readonly canvas: TextureCanvas;
private readonly picker: Dropdown;
private readonly info: Label;
private readonly sliceButton: Button;
private readonly headerBg = new Graphics();
constructor(private readonly state: EditorState) {
super();
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
header.addChildAt(this.headerBg, 0);
header.add(new Label('Texture', { style: 'muted' }));
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
header.add(this.picker);
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
header.add(this.sliceButton);
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
header.add(new Spacer(), 1);
this.info = new Label('', { style: 'small', align: 'right' });
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
// the text it had at the time – so without a floor the readout is clipped from then on.
this.info.minWidth = INFO_WIDTH;
header.add(this.info);
this.canvas = new TextureCanvas(state);
this.add(header);
this.add(this.canvas, 1);
state.on('selection', () => this.followSelection());
state.on('project', () => this.refresh());
state.on('load', () => {
this.canvas.show('');
this.followSelection();
});
this.followSelection();
}
/** The texture currently shown – the check scripts read this. */
get currentAssetId(): string {
return this.canvas.currentAssetId;
}
/** Shows a texture explicitly (opening the tab for a freshly imported image). */
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
}
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
imageToScreen(x: number, y: number): { x: number; y: number } {
return this.canvas.imageToScreen(x, y);
}
private followSelection(): void {
const id = this.state.selectedTextureId;
// Null merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? '');
this.refresh();
}
private asset(): TextureAsset | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && asset.type === 'texture' ? asset : null;
}
private textures(): TextureAsset[] {
return this.state.project.assets
.filter((a): a is TextureAsset => a.type === 'texture')
.sort((a, b) => a.name.localeCompare(b.name));
}
private openPicker(): void {
const items: MenuItem[] = this.textures().map((asset) => ({
label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name,
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
}));
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
}
private openSlice(): void {
const asset = this.asset();
if (!asset) return;
new SliceDialog(asset, (options, replace) => {
this.state.execute(new SliceFramesCommand(asset.id, options, replace));
}).openCentered();
}
private refresh(): void {
const asset = this.asset();
this.picker.setText(asset ? asset.name : 'No texture');
const count = asset?.frames.length ?? 0;
this.info.setText(
asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '',
);
// Slicing needs a known picture size; without one it could only fail with a notice.
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
}
override redraw(): void {
super.redraw();
this.headerBg.clear();
const h = Theme.controlHeight + Theme.padding * 2;
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* How the Texture tab maps image pixels onto the widget, and what a drag on a frame means.
* Pure geometry – no Pixi, no editor state – so the fiddly part of the editor (which handle
* did I grab, what does dragging it do) is unit-testable instead of only clickable.
*/
import type { FrameRect } from '../../core/model/texture';
import type { Vec2 } from '../../core/model/Properties';
/** Image pixel `(0, 0)` sits at (`panX`, `panY`) in widget-local pixels, at `zoom` per pixel. */
export interface SheetView {
panX: number;
panY: number;
zoom: number;
}
export const MIN_SHEET_ZOOM = 0.05;
export const MAX_SHEET_ZOOM = 64;
/** Fraction of the widget the picture is fitted into, leaving a margin to grab and pan by. */
const FIT_MARGIN = 0.9;
export function imageToLocal(view: SheetView, x: number, y: number): Vec2 {
return { x: view.panX + x * view.zoom, y: view.panY + y * view.zoom };
}
export function localToImage(view: SheetView, x: number, y: number): Vec2 {
return { x: (x - view.panX) / view.zoom, y: (y - view.panY) / view.zoom };
}
/** The view that centres the whole picture in a `w`×`h` widget. */
export function fitView(imageWidth: number, imageHeight: number, w: number, h: number): SheetView {
if (imageWidth <= 0 || imageHeight <= 0 || w <= 0 || h <= 0) return { panX: 0, panY: 0, zoom: 1 };
const zoom = clampZoom(Math.min(w / imageWidth, h / imageHeight) * FIT_MARGIN || 1);
return {
panX: (w - imageWidth * zoom) / 2,
panY: (h - imageHeight * zoom) / 2,
zoom,
};
}
export function clampZoom(zoom: number): number {
return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom));
}
/** Zooms about a fixed widget-local point, so the pixel under the cursor stays under it. */
export function zoomAbout(view: SheetView, factor: number, localX: number, localY: number): SheetView {
const zoom = clampZoom(view.zoom * factor);
const before = localToImage(view, localX, localY);
const after = { panX: localX - before.x * zoom, panY: localY - before.y * zoom, zoom };
return after;
}
/**
* Which part of a frame a point grabs: a corner or edge handle (`hx`/`hy` in −1/0/1, naming
* the edge each axis is pinned to), `'move'` anywhere else inside it, or null outside.
*/
export type FrameHandle = { hx: -1 | 0 | 1; hy: -1 | 0 | 1 } | 'move';
/** The nine anchor points of a rectangle; (0, 0) is its centre and is not a handle. */
const HANDLES: { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] = [
{ hx: -1, hy: -1 },
{ hx: 1, hy: -1 },
{ hx: -1, hy: 1 },
{ hx: 1, hy: 1 },
{ hx: 0, hy: -1 },
{ hx: 0, hy: 1 },
{ hx: -1, hy: 0 },
{ hx: 1, hy: 0 },
];
/** Where a handle sits, in image pixels. */
export function handlePoint(rect: FrameRect, handle: { hx: number; hy: number }): Vec2 {
return {
x: rect.x + ((handle.hx + 1) / 2) * rect.w,
y: rect.y + ((handle.hy + 1) / 2) * rect.h,
};
}
export function frameHandles(): readonly { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] {
return HANDLES;
}
/**
* What a press at `point` (image pixels) does to `rect`. `tolerance` is the grab radius in
* image pixels – the caller converts a constant screen distance, so handles stay equally easy
* to hit at every zoom.
*
* Corners are tested before edges so that at a small frame, where all handles overlap, the
* corner wins: a corner resizes both axes and is the one worth reaching for.
*/
export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): FrameHandle | null {
for (const handle of HANDLES) {
const p = handlePoint(rect, handle);
if (Math.abs(point.x - p.x) <= tolerance && Math.abs(point.y - p.y) <= tolerance) return handle;
}
const inside =
point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h;
return inside ? 'move' : null;
}
/**
* The rectangle a drag produces: `handle` decides which edges follow the pointer. The result
* may be inverted or stick out of the image – squaring it up is `normalizeFrameRect`'s job,
* so that a drag and a typed-in value are corrected by exactly one piece of code.
*/
export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy: number): FrameRect {
if (handle === 'move') return { ...start, x: start.x + dx, y: start.y + dy };
const rect = { ...start };
if (handle.hx === -1) {
rect.x += dx;
rect.w -= dx;
} else if (handle.hx === 1) {
rect.w += dx;
}
if (handle.hy === -1) {
rect.y += dy;
rect.h -= dy;
} else if (handle.hy === 1) {
rect.h += dy;
}
return rect;
}
/** The rectangle spanned by two points, in either order. */
export function rectBetween(a: Vec2, b: Vec2): FrameRect {
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
}
/** The mouse cursor a handle asks for, so a resize edge announces itself before it is grabbed. */
export function handleCursor(handle: FrameHandle): string {
if (handle === 'move') return 'move';
if (handle.hx === 0) return 'ns-resize';
if (handle.hy === 0) return 'ew-resize';
return handle.hx === handle.hy ? 'nwse-resize' : 'nesw-resize';
}
+25 -4
View File
@@ -3,11 +3,13 @@
* separate Pixi Text), and the editor symbols for cameras, behaviors, containers and
* colliders. Everything here draws in world units at the item's origin.
*/
import { type Graphics, Rectangle } from 'pixi.js';
import { type Graphics, Matrix, Rectangle, type Texture } from 'pixi.js';
import type { TextureRef } from '../core/model/texture';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import { frameFillMatrix } from './textureFill';
import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
@@ -32,10 +34,9 @@ export function drawItem(
// Null while the picture is still loading, so the box shows its plain colour until then –
// which is exactly what a sprite looked like before textures existed. No flicker, no
// placeholder. With a picture the colour tints it (verified: white x 0x66aaff reads back
// as rgb(98,163,245)), and Pixi fits an unmatrixed texture fill to the shape's bounds,
// so nothing here has to compute a texture matrix.
// as rgb(98,163,245)).
const texture = item.texture ? textures.get(item.texture) : null;
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(texture ? { texture, color } : color);
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color, item.texture));
return;
}
case 'camera':
@@ -101,6 +102,26 @@ export function drawItem(
}
}
/**
* The fill a sprite's box takes: the plain colour while there is no picture, the picture
* tinted by that colour once there is one.
*
* Only a frame gets a matrix. An unmatrixed fill spreads the whole image over the shape, which
* is precisely what a whole-image sprite wants, and skipping the matrix there saves Pixi the
* inversion on every redraw of every such sprite. See `textureFill.ts` for why a frame cannot
* be drawn as a Pixi sub-texture instead.
*/
function spriteFill(
texture: Texture | null,
color: number,
ref: TextureRef | null,
): number | { texture: Texture; color: number; matrix?: Matrix } {
if (!texture || !ref) return color;
const m = ref.rect && frameFillMatrix(ref.rect, ref.sourceWidth, ref.sourceHeight);
if (!m) return { texture, color };
return { texture, color, matrix: new Matrix(m.a, m.b, m.c, m.d, m.tx, m.ty) };
}
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
function colliderOutlineHitArea(
shape: ColliderShape,
+48
View File
@@ -0,0 +1,48 @@
import type { FrameRect } from '../core/model/texture';
/** The six numbers of a 2D affine matrix, in Pixi's `Matrix(a, b, c, d, tx, ty)` order. */
export interface MatrixValues {
a: number;
b: number;
c: number;
d: number;
tx: number;
ty: number;
}
/**
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight`
* image covers the filled shape – how a sprite wears one frame of a sheet.
*
* Measured, not assumed. Two things about Pixi v8 make this the answer:
*
* - A `Texture` built with a `frame` reports the frame's size, but a Graphics **fill ignores
* it** and draws the whole source. Verified by reading back pixels: a fill with a 32×32
* frame of a four-quadrant sheet showed all four quadrants. Sub-textures are therefore not
* a way to draw a frame.
* - `generateTextureMatrix` computes `uv = inverse(style.matrix) ∘ normalise(shapeBounds)`,
* where the normaliser maps the shape's bounding box onto the unit square. So an absent
* matrix spreads the whole image over the shape (which is why a whole-image sprite needs
* none), and to spread a sub-rectangle instead, `inverse(style.matrix)` has to be the map
* from the unit square onto that rectangle in UV space – making the matrix its inverse.
*
* Because the normaliser works off the shape's own bounds, this is independent of where the
* shape sits and how big it is; verified with an off-origin, non-square shape.
*/
export function frameFillMatrix(
rect: FrameRect,
sourceWidth: number,
sourceHeight: number,
): MatrixValues | null {
// Without a known image size the rectangle has no reference frame; drawing the whole
// picture is the honest answer, and it is what the sprite showed a moment ago anyway.
if (sourceWidth <= 0 || sourceHeight <= 0 || rect.w <= 0 || rect.h <= 0) return null;
return {
a: sourceWidth / rect.w,
b: 0,
c: 0,
d: sourceHeight / rect.h,
tx: -rect.x / rect.w,
ty: -rect.y / rect.h,
};
}
+282
View File
@@ -0,0 +1,282 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import {
AddFrameCommand,
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
SliceFramesCommand,
} from '../../src/core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { DEFAULT_SLICE, findFrame, type FrameRect } from '../../src/core/model/texture';
import { makeProject } from '../helpers';
const FILE = `${'ab'.repeat(32)}.png`;
const GRID_2X2 = { ...DEFAULT_SLICE, columns: 2, rows: 2 };
/** A project holding one 64×64 texture, with lookups that survive undo replacing the asset. */
function makeSheet(width = 64, height = 64) {
const fixture = makeProject();
const create = new CreateTextureCommand('Sheet', { file: FILE, width, height });
fixture.stack.execute(create);
const asset = () => findAsset(fixture.project, create.asset.id) as TextureAsset;
return { ...fixture, textureId: create.asset.id, asset, frames: () => asset().frames };
}
/** Adds a frame through the command stack and returns its id. */
function addFrame(sheet: ReturnType<typeof makeSheet>, rect: FrameRect, name?: string): string {
const command = new AddFrameCommand(sheet.textureId, rect, name);
sheet.stack.execute(command);
return command.frameId;
}
/** A detached copy of the frame list, to compare a state against after an undo. */
function snapshot(frames: readonly TextureFrame[]): TextureFrame[] {
return frames.map((f) => ({ ...f }));
}
describe('AddFrameCommand', () => {
it('appends a named frame and removes exactly that one again', () => {
const sheet = makeSheet();
const first = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle');
const before = snapshot(sheet.frames());
const second = addFrame(sheet, { x: 16, y: 0, w: 16, h: 16 }, 'Run');
expect(sheet.frames()).toEqual([
{ id: first, name: 'Idle', x: 0, y: 0, w: 16, h: 16 },
{ id: second, name: 'Run', x: 16, y: 0, w: 16, h: 16 },
]);
// The id is known before execution, so the editor can select the frame it just made.
expect(findFrame(sheet.project, second)?.frame.name).toBe('Run');
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.id)).toEqual([first, second]);
});
it('names frames apart instead of repeating one', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Idle');
addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 });
addFrame(sheet, { x: 24, y: 0, w: 8, h: 8 });
// Numbered from 2, like every other generated name in a document (see `nextAssetName`).
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2', 'Frame', 'Frame 2']);
});
it('stores a fractional, inverted drag as whole pixels with a positive size', () => {
const sheet = makeSheet();
// Dragging up and to the left in a zoomed view: negative extents and fractions.
addFrame(sheet, { x: 10.4, y: 5, w: -6.2, h: 9.9 });
expect(sheet.frames()[0]).toMatchObject({ x: 4, y: 5, w: 6, h: 10 });
});
it('clamps a rectangle dragged past the edge into the image', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 50, y: 50, w: 40, h: 40 });
addFrame(sheet, { x: -20, y: -5, w: 30, h: 30 });
expect(sheet.frames()[0]).toMatchObject({ x: 50, y: 50, w: 14, h: 14 });
expect(sheet.frames()[1]).toMatchObject({ x: 0, y: 0, w: 10, h: 25 });
});
it('rejects a texture that is not there, and does so in execute', () => {
const sheet = makeSheet();
const command = new AddFrameCommand('nope', { x: 0, y: 0, w: 4, h: 4 });
expect(() => sheet.stack.execute(command)).toThrow(CommandError);
expect(sheet.frames()).toHaveLength(0);
});
});
describe('SetFrameRectCommand', () => {
it('moves a frame and puts the old rectangle back', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle');
const before = snapshot(sheet.frames());
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, { x: 20.6, y: 30, w: 10, h: -8 }));
expect(sheet.frames()[0]).toMatchObject({ name: 'Idle', x: 21, y: 22, w: 10, h: 8 });
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames()[0]).toMatchObject({ x: 21, y: 22 });
});
it('records the rectangle the drag started from, which the caller restores first', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 });
const frame = sheet.frames()[0]!;
// What a live preview does: write into the frame, then hand the command the previewed
// rectangle and put the starting one back before executing (see `SceneViewport`).
frame.x = 40;
const previewed = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
frame.x = 0;
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, previewed));
expect(sheet.frames()[0]).toMatchObject({ x: 40 });
sheet.stack.undo();
expect(sheet.frames()[0]).toMatchObject({ x: 0 });
});
it('rejects a frame that is gone', () => {
const sheet = makeSheet();
expect(() =>
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, 'nope', { x: 0, y: 0, w: 4, h: 4 })),
).toThrow(CommandError);
});
});
describe('RenameFrameCommand', () => {
it('renames with undo and keeps the name unique', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
const second = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Run');
const before = snapshot(sheet.frames());
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Walk'));
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Walk']);
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Idle'));
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2']);
sheet.stack.undo();
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
});
it('leaves a frame its own name to rename to', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, 'Idle'));
expect(sheet.frames()[0]?.name).toBe('Idle');
});
it('refuses a blank name instead of showing a nameless frame', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
expect(() => sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, ' '))).toThrow(
CommandError,
);
expect(sheet.frames()[0]?.name).toBe('Idle');
});
});
describe('RemoveFrameCommand', () => {
it('puts the frame back at its original index, not at the end', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'A');
const middle = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'B');
addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 }, 'C');
const before = snapshot(sheet.frames());
sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, middle));
expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']);
sheet.stack.undo();
// The frame order is the order of the sheet, so appending it again would reshuffle it.
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']);
});
it('rejects a frame that is not in the texture', () => {
const sheet = makeSheet();
expect(() => sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, 'nope'))).toThrow(CommandError);
});
});
describe('SliceFramesCommand', () => {
it('cuts a grid into equal cells in reading order', () => {
const sheet = makeSheet();
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(sheet.frames().map((f) => ({ x: f.x, y: f.y, w: f.w, h: f.h }))).toEqual([
{ x: 0, y: 0, w: 32, h: 32 },
{ x: 32, y: 0, w: 32, h: 32 },
{ x: 0, y: 32, w: 32, h: 32 },
{ x: 32, y: 32, w: 32, h: 32 },
]);
expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']);
sheet.stack.undo();
expect(sheet.frames()).toEqual([]);
sheet.stack.redo();
expect(sheet.frames()).toHaveLength(4);
});
it('keeps existing frames when adding, and replaces them when told to', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 1, y: 2, w: 8, h: 8 }, 'Hand-made');
const before = snapshot(sheet.frames());
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(sheet.frames()).toHaveLength(5);
expect(sheet.frames()[0]).toEqual(before[0]);
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, true));
expect(sheet.frames()).toHaveLength(4);
expect(sheet.frames().some((f) => f.name === 'Hand-made')).toBe(false);
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']);
});
it('gives a redone slice the same frame ids, so a sprite wearing one keeps its picture', () => {
const sheet = makeSheet();
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
const ids = sheet.frames().map((f) => f.id);
sheet.stack.undo();
sheet.stack.redo();
expect(sheet.frames().map((f) => f.id)).toEqual(ids);
});
it('numbers generated names around the ones already taken', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Frame 2');
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(new Set(sheet.frames().map((f) => f.name)).size).toBe(5);
});
it('rejects a slice of an image whose size is unknown rather than doing nothing', () => {
// Width 0 is a texture whose upload has not been measured yet; slicing it would silently
// produce no frames at all.
const sheet = makeSheet(0, 0);
expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false))).toThrow(
CommandError,
);
expect(sheet.frames()).toEqual([]);
});
it('rejects a grid whose cells would be smaller than a pixel', () => {
const sheet = makeSheet(4, 4);
const tooFine = { ...DEFAULT_SLICE, columns: 8, rows: 8 };
expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, tooFine, true))).toThrow(
CommandError,
);
expect(sheet.frames()).toEqual([]);
});
it('refuses an asset that is missing, and one that is not a texture', () => {
const sheet = makeSheet();
const sprite = new CreateAssetCommand('sprite', 'Box');
sheet.stack.execute(sprite);
expect(() => sheet.stack.execute(new SliceFramesCommand('nope', GRID_2X2, false))).toThrow(CommandError);
expect(() => sheet.stack.execute(new SliceFramesCommand(sprite.asset.id, GRID_2X2, false))).toThrow(
/not a texture/,
);
});
});
+111 -3
View File
@@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest';
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import type { TextureAsset } from '../../src/core/model/Asset';
import { findAsset, findPropertyUsages, findUsages } from '../../src/core/model/Project';
import { isTextureFile, resolveTexture } from '../../src/core/model/texture';
import {
DEFAULT_SLICE,
isTextureFile,
nextFrameNames,
resolveTexture,
sliceFrames,
textureRefIds,
uniqueFrameName,
} from '../../src/core/model/texture';
import { deserializeProject } from '../../src/core/store/serialize';
import { makeSpriteScene, place, roundTrip } from '../helpers';
@@ -43,9 +51,33 @@ describe('resolveTexture', () => {
const sprite = findAsset(fixture.project, fixture.spriteId)!;
sprite.props.texture = texture.id;
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: true });
// A whole-texture reference carries no rectangle: the renderer then needs no matrix at all.
const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48 };
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true });
texture.props.filter = 'nearest';
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: false });
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false });
});
it('resolves a frame id to the rectangle it cuts, not to the whole picture', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
texture.frames.push({ id: 'f1', name: 'Idle', x: 16, y: 8, w: 32, h: 24 });
const sprite = findAsset(fixture.project, fixture.spriteId)!;
// The same property holds either kind of id, which is what keeps sprites written before
// frames existed meaning exactly what they meant.
sprite.props.texture = 'f1';
expect(resolveTexture(fixture.project, sprite.props)).toEqual({
file: FILE,
smooth: true,
rect: { x: 16, y: 8, w: 32, h: 24 },
sourceWidth: 64,
sourceHeight: 48,
});
// A frame that is gone dangles like any other reference: no picture, not a wrong one.
texture.frames.length = 0;
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
});
it('yields nothing rather than throwing when the reference cannot be used', () => {
@@ -127,6 +159,18 @@ describe('findPropertyUsages', () => {
expect(findPropertyUsages(fixture.project, texture.id).some((u) => u.on === 'object')).toBe(true);
});
it('counts sprites wearing a frame of the texture, not just the texture itself', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 8, h: 8 });
findAsset(fixture.project, fixture.spriteId)!.props.texture = 'f1';
// Asking by the asset id alone under-reports exactly the sheets that were sliced – the
// ones with the most users – which is why deleting one asks about every id it owns.
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(0);
expect(findPropertyUsages(fixture.project, textureRefIds(texture))).toHaveLength(1);
});
it('ignores unrelated ids and an empty reference', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
@@ -137,3 +181,67 @@ describe('findPropertyUsages', () => {
expect(findPropertyUsages(fixture.project, '')).toHaveLength(0);
});
});
describe('slicing a sheet', () => {
it('cuts a grid in reading order and derives the cell size from the picture', () => {
const rects = sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 2, rows: 2 });
expect(rects).toEqual([
{ x: 0, y: 0, w: 32, h: 32 },
{ x: 32, y: 0, w: 32, h: 32 },
{ x: 0, y: 32, w: 32, h: 32 },
{ x: 32, y: 32, w: 32, h: 32 },
]);
});
it('accounts for an offset and for spacing between cells', () => {
// 2 columns with a 2 px border and 4 px between them: (64 − 2 − 4) / 2 = 29 px cells.
const rects = sliceFrames(64, 64, {
...DEFAULT_SLICE,
columns: 2,
rows: 1,
offsetX: 2,
spacingX: 4,
});
expect(rects).toEqual([
{ x: 2, y: 0, w: 29, h: 64 },
{ x: 35, y: 0, w: 29, h: 64 },
]);
});
it('refuses rather than guessing when the numbers cannot describe a grid', () => {
// An unmeasured picture, more cells than pixels, and the values a form can produce.
expect(sliceFrames(0, 0, DEFAULT_SLICE)).toEqual([]);
expect(sliceFrames(4, 4, { ...DEFAULT_SLICE, columns: 8, rows: 8 })).toEqual([]);
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 0 })).toEqual([]);
// NaN passes every `<` comparison, so it has to be rejected in its own right.
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: NaN })).toEqual([]);
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, spacingX: NaN })).toEqual([]);
});
});
describe('frame names', () => {
const sheet = (...names: string[]): TextureAsset => ({
id: 't',
type: 'texture',
name: 'Sheet',
props: {},
file: '',
width: 64,
height: 64,
frames: names.map((name, i) => ({ id: `f${i}`, name, x: 0, y: 0, w: 1, h: 1 })),
});
it('numbers a clashing name from 2, the way asset names are numbered', () => {
expect(uniqueFrameName(sheet(), 'Idle')).toBe('Idle');
expect(uniqueFrameName(sheet('Idle'), 'Idle')).toBe('Idle 2');
expect(uniqueFrameName(sheet('Idle', 'Idle 2'), 'Idle')).toBe('Idle 3');
// A frame keeps its own name available, so renaming it to itself is not a clash.
expect(uniqueFrameName(sheet('Idle'), 'Idle', 'f0')).toBe('Idle');
});
it('numbers a whole slice from 1 and steps over names already taken', () => {
expect(nextFrameNames(sheet(), 3)).toEqual(['Frame 1', 'Frame 2', 'Frame 3']);
// Re-slicing a sheet that keeps its frames must not append a number to a number.
expect(nextFrameNames(sheet('Frame 2'), 3)).toEqual(['Frame 1', 'Frame 3', 'Frame 4']);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import type { FrameRect } from '../../src/core/model/texture';
import {
dragFrame,
fitView,
type FrameHandle,
handleAt,
handleCursor,
handlePoint,
imageToLocal,
localToImage,
MAX_SHEET_ZOOM,
MIN_SHEET_ZOOM,
rectBetween,
zoomAbout,
} from '../../src/editor/texture/sheetView';
const RECT: FrameRect = { x: 10, y: 20, w: 40, h: 30 };
describe('sheet view mapping', () => {
it('maps image pixels to the widget and back', () => {
const view = { panX: 100, panY: 50, zoom: 2 };
expect(imageToLocal(view, 0, 0)).toEqual({ x: 100, y: 50 });
expect(imageToLocal(view, 8, 4)).toEqual({ x: 116, y: 58 });
// Round trip: the two are inverses, which is what lets a drag be computed in either space.
expect(localToImage(view, 116, 58)).toEqual({ x: 8, y: 4 });
});
it('fits a picture centred, with the same margin on both axes', () => {
// A 100x50 picture in a 400x400 widget is limited by its width.
const view = fitView(100, 50, 400, 400);
expect(view.zoom).toBeCloseTo(3.6);
expect(view.panX).toBeCloseTo((400 - 100 * 3.6) / 2);
expect(view.panY).toBeCloseTo((400 - 50 * 3.6) / 2);
// The whole picture is inside the widget, which is the only thing "fit" has to guarantee.
const end = imageToLocal(view, 100, 50);
expect(view.panX).toBeGreaterThanOrEqual(0);
expect(end.x).toBeLessThanOrEqual(400);
expect(end.y).toBeLessThanOrEqual(400);
});
it('has a defined answer for a picture that has not been measured', () => {
expect(fitView(0, 0, 400, 400)).toEqual({ panX: 0, panY: 0, zoom: 1 });
expect(fitView(100, 50, 0, 0)).toEqual({ panX: 0, panY: 0, zoom: 1 });
});
it('keeps the pixel under the cursor under the cursor while zooming', () => {
const view = { panX: 100, panY: 50, zoom: 2 };
const before = localToImage(view, 300, 260);
const zoomed = zoomAbout(view, 1.5, 300, 260);
expect(zoomed.zoom).toBeCloseTo(3);
expect(localToImage(zoomed, 300, 260).x).toBeCloseTo(before.x);
expect(localToImage(zoomed, 300, 260).y).toBeCloseTo(before.y);
});
it('clamps the zoom at both ends', () => {
const view = { panX: 0, panY: 0, zoom: 1 };
expect(zoomAbout(view, 1e6, 0, 0).zoom).toBe(MAX_SHEET_ZOOM);
expect(zoomAbout(view, 1e-6, 0, 0).zoom).toBe(MIN_SHEET_ZOOM);
});
});
describe('grabbing a frame', () => {
it('finds each of the eight handles at its own corner or edge', () => {
expect(handlePoint(RECT, { hx: -1, hy: -1 })).toEqual({ x: 10, y: 20 });
expect(handlePoint(RECT, { hx: 1, hy: 1 })).toEqual({ x: 50, y: 50 });
expect(handlePoint(RECT, { hx: 0, hy: -1 })).toEqual({ x: 30, y: 20 });
expect(handleAt(RECT, { x: 10, y: 20 }, 2)).toEqual({ hx: -1, hy: -1 });
expect(handleAt(RECT, { x: 50, y: 20 }, 2)).toEqual({ hx: 1, hy: -1 });
expect(handleAt(RECT, { x: 30, y: 50 }, 2)).toEqual({ hx: 0, hy: 1 });
});
it('grabs the inside as a move and misses outside entirely', () => {
expect(handleAt(RECT, { x: 30, y: 35 }, 2)).toBe('move');
expect(handleAt(RECT, { x: 5, y: 35 }, 2)).toBeNull();
expect(handleAt(RECT, { x: 30, y: 60 }, 2)).toBeNull();
});
it('prefers a corner where handles overlap, so a tiny frame stays resizable', () => {
// A 4x4 frame with a 6 px tolerance: every handle matches the centre of the frame, and
// grabbing it must resize rather than move, or a small frame could never be adjusted.
const tiny: FrameRect = { x: 0, y: 0, w: 4, h: 4 };
expect(handleAt(tiny, { x: 2, y: 2 }, 6)).toEqual({ hx: -1, hy: -1 });
});
it('names a cursor for every handle', () => {
expect(handleCursor('move')).toBe('move');
expect(handleCursor({ hx: -1, hy: -1 })).toBe('nwse-resize');
expect(handleCursor({ hx: 1, hy: -1 })).toBe('nesw-resize');
expect(handleCursor({ hx: 0, hy: 1 })).toBe('ns-resize');
expect(handleCursor({ hx: -1, hy: 0 })).toBe('ew-resize');
});
});
describe('dragging a frame', () => {
it('moves without changing the size', () => {
expect(dragFrame(RECT, 'move', 5, -7)).toEqual({ x: 15, y: 13, w: 40, h: 30 });
});
it('pins the opposite edge when a corner is dragged', () => {
// Dragging the top-left corner right and down shrinks the frame; the bottom-right stays.
const dragged = dragFrame(RECT, { hx: -1, hy: -1 }, 4, 6);
expect(dragged).toEqual({ x: 14, y: 26, w: 36, h: 24 });
expect(dragged.x + dragged.w).toBe(RECT.x + RECT.w);
expect(dragged.y + dragged.h).toBe(RECT.y + RECT.h);
});
it('moves only its own axis when an edge is dragged', () => {
expect(dragFrame(RECT, { hx: 0, hy: 1 }, 9, 5)).toEqual({ x: 10, y: 20, w: 40, h: 35 });
expect(dragFrame(RECT, { hx: 1, hy: 0 }, 9, 5)).toEqual({ x: 10, y: 20, w: 49, h: 30 });
});
it('is allowed to invert – squaring that up belongs to one place only', () => {
// Pulling the left edge past the right one yields a negative width on purpose:
// `normalizeFrameRect` corrects it, so a drag and a typed value are corrected identically.
const dragged = dragFrame(RECT, { hx: -1, hy: 0 }, 60, 0);
expect(dragged.w).toBeLessThan(0);
});
it('spans a rectangle between two points in either order', () => {
const forward = rectBetween({ x: 10, y: 20 }, { x: 30, y: 50 });
expect(forward).toEqual({ x: 10, y: 20, w: 20, h: 30 });
expect(rectBetween({ x: 30, y: 50 }, { x: 10, y: 20 })).toEqual(forward);
});
it('walks a frame through the eight handles back to where it started', () => {
// Each handle dragged by (d) and then by (−d) is the identity; a sign error in any one of
// the eight branches shows up here without needing eight separate expectations.
const handles: FrameHandle[] = [
'move',
{ hx: -1, hy: -1 },
{ hx: 1, hy: -1 },
{ hx: -1, hy: 1 },
{ hx: 1, hy: 1 },
{ hx: 0, hy: -1 },
{ hx: 0, hy: 1 },
{ hx: -1, hy: 0 },
{ hx: 1, hy: 0 },
];
for (const handle of handles)
expect(dragFrame(dragFrame(RECT, handle, 3, 4), handle, -3, -4)).toEqual(RECT);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* The fill matrix is what makes a sprite wear one frame of a sheet, and it is only ever seen
* as pixels on a canvas – so it is pinned here against the identity its doc comment states,
* `uv = inverse(matrix) ∘ normalise(shapeBounds)`, rather than against the formula it uses.
* The checks therefore invert the matrix themselves and ask where the corners of the shape
* land in the image; that is an independent statement of what the renderer must achieve.
*/
import { describe, expect, it } from 'vitest';
import type { FrameRect } from '../../src/core/model/texture';
import { frameFillMatrix, type MatrixValues } from '../../src/viewport/textureFill';
/**
* Where the shape point `(u, v)` – normalised, so (0,0) is the shape's top-left corner and
* (1,1) its bottom-right – samples the image, in UV coordinates over the whole picture.
* This is Pixi's `inverse(style.matrix)` applied to the normalised point, written out for a
* general affine matrix so the test does not assume the axis-aligned shape of the real one.
*/
function sampleAt(m: MatrixValues, u: number, v: number): { u: number; v: number } {
const det = m.a * m.d - m.b * m.c;
const dx = u - m.tx;
const dy = v - m.ty;
return { u: (m.d * dx - m.c * dy) / det, v: (m.a * dy - m.b * dx) / det };
}
/** Asserts that the shape's four corners sample exactly the four corners of `rect`. */
function expectCovers(m: MatrixValues, rect: FrameRect, sourceWidth: number, sourceHeight: number): void {
const left = rect.x / sourceWidth;
const right = (rect.x + rect.w) / sourceWidth;
const top = rect.y / sourceHeight;
const bottom = (rect.y + rect.h) / sourceHeight;
for (const [u, v, expectedU, expectedV] of [
[0, 0, left, top],
[1, 0, right, top],
[0, 1, left, bottom],
[1, 1, right, bottom],
] as const) {
const got = sampleAt(m, u, v);
expect(got.u).toBeCloseTo(expectedU, 12);
expect(got.v).toBeCloseTo(expectedV, 12);
}
}
/**
* Compares the six numbers one by one instead of the object as a whole: a frame sitting on the
* top or left edge produces a negative zero, which `toEqual` counts as a different value.
*/
function expectMatrix(m: MatrixValues, expected: MatrixValues): void {
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) expect(m[key]).toBeCloseTo(expected[key], 12);
}
describe('frameFillMatrix', () => {
it('declines when the image size is unknown, so the sprite keeps showing the whole picture', () => {
const rect = { x: 0, y: 0, w: 32, h: 32 };
// What an upload that has not reported its size yet looks like in the model.
expect(frameFillMatrix(rect, 0, 0)).toBeNull();
expect(frameFillMatrix(rect, 64, 0)).toBeNull();
expect(frameFillMatrix(rect, -64, 64)).toBeNull();
});
it('declines for a frame with no area, which cannot be spread over anything', () => {
expect(frameFillMatrix({ x: 0, y: 0, w: 0, h: 32 }, 64, 64)).toBeNull();
expect(frameFillMatrix({ x: 0, y: 0, w: 32, h: 0 }, 64, 64)).toBeNull();
});
it('maps the shape onto the top-right quadrant of a four-quadrant sheet', () => {
const rect = { x: 32, y: 0, w: 32, h: 32 };
const m = frameFillMatrix(rect, 64, 64)!;
// Half the sheet across, so the shape has to be stretched twofold in both directions, and
// shifted by one shape-width to start at the sheet's midpoint.
expectMatrix(m, { a: 2, b: 0, c: 0, d: 2, tx: -1, ty: 0 });
expectCovers(m, rect, 64, 64);
});
it('stretches the axes independently for a non-square frame', () => {
// A column strip: an eighth of the sheet wide and its full height.
const rect = { x: 0, y: 0, w: 16, h: 64 };
const m = frameFillMatrix(rect, 128, 64)!;
expectMatrix(m, { a: 8, b: 0, c: 0, d: 1, tx: 0, ty: 0 });
expectCovers(m, rect, 128, 64);
});
it('offsets in frame-widths, not in pixels or UV', () => {
// A small frame well inside a large sheet: three frame-widths from the left, two frame
// heights down. This is the case that catches an offset expressed in the wrong unit,
// because pixels, UV and frame-widths all differ here.
const rect = { x: 48, y: 16, w: 16, h: 8 };
const m = frameFillMatrix(rect, 256, 128)!;
expectMatrix(m, { a: 16, b: 0, c: 0, d: 16, tx: -3, ty: -2 });
expectCovers(m, rect, 256, 128);
});
it('is the identity for a frame covering the whole sheet', () => {
// Which is the reason a whole-image sprite is drawn with no matrix at all: the matrix it
// would get says nothing, and an absent one is what Pixi is fastest at.
const rect = { x: 0, y: 0, w: 200, h: 100 };
const m = frameFillMatrix(rect, 200, 100)!;
expectMatrix(m, { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 });
expectCovers(m, rect, 200, 100);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* The Texture tab: cutting frames out of a sheet, and a sprite wearing one.
*
* Runs after the texture step, whose four-quadrant 64x48 picture is what makes this checkable
* at all – a frame over one quadrant has a colour of its own, so "which part of the sheet is
* on screen" is a question `pixelAt` can answer. Nothing here is stubbed: the frame is made by
* dragging on the canvas, and the sprite is read back off the rendered pixels.
*/
/** How far apart two `#rrggbb` colours are, summed over the channels. */
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
const [br, bg, bb] = channels(b);
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
}
/** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */
const PALETTE = {
red: '#e63c3c',
green: '#3cc85a',
blue: '#466ef0',
white: '#f5f5f5',
/**
* The sprite's own colour for the fallback check, deliberately a colour the sheet does not
* contain. The sheet's fourth quadrant is near-white, so leaving the sprite white would make
* "it fell back to its colour" and "it is showing that quadrant" the same measurement.
*/
plain: '#ff00ff',
};
/**
* Which of the known colours a sampled pixel is: the nearest one by channel distance.
*
* Asked this way rather than "is it exactly green" on purpose. The scene carries editor gizmos
* by this point – a collider's fill is drawn over its body at 12 % alpha – so a sample can be
* tinted by a few percent without anything being wrong. That shifts a colour slightly; it
* cannot make one quadrant of the sheet read as another, which is the only question here.
*/
function nearestColour(sample) {
let best = '';
let bestDistance = Infinity;
for (const [name, hex] of Object.entries(PALETTE)) {
const distance = colourDistance(sample, hex);
if (distance < bestDistance) {
bestDistance = distance;
best = name;
}
}
return best;
}
export async function frames(t) {
const { check, sleep, json, evaluate, shot, clickText, drag, press, undo } = t;
// 20. The tab exists and shows the texture the selection points at.
await press('Escape');
await evaluate('__gm.state.select(null)');
const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(texture)} })`);
await clickText('Texture');
await sleep(600);
check('the Texture tab opens', (await json('__gm.tab()')) === 'texture');
check(
'and shows the selected sheet',
(await json('__gm.texture.current()')) === texture,
`${await json('__gm.texture.current()')} vs ${texture}`,
);
await shot('33-texture-tab');
// 21. Dragging on the picture cuts a frame. The drag is aimed at the top-right quadrant of
// the sheet (32,0)-(64,24), inset by a pixel so the press cannot land on the edge of a
// neighbouring frame and be read as a resize instead of a new rectangle.
const from = await json('__gm.texture.pointAt(33, 1)');
const to = await json('__gm.texture.pointAt(63, 23)');
check('the sheet is on screen', Boolean(from) && Boolean(to), JSON.stringify({ from, to }));
await drag(from.x, from.y, to.x, to.y);
await sleep(500);
const cut = await json(
`(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []`,
);
check('dragging on the sheet cuts a frame', cut.length === 1, JSON.stringify(cut));
const frame = cut[0] ?? {};
check(
'the frame lands on the quadrant it was dragged over',
frame.x >= 32 && frame.y >= 0 && frame.x + frame.w <= 64 && frame.y + frame.h <= 48,
JSON.stringify(frame),
);
check(
'and is stored in whole pixels',
Number.isInteger(frame.x) && Number.isInteger(frame.y) && Number.isInteger(frame.w),
JSON.stringify(frame),
);
check(
'cutting a frame selects it, so the inspector edits what was just made',
(await json('__gm.state.selection?.kind')) === 'frame',
JSON.stringify(await json('__gm.state.selection')),
);
await shot('34-frame-cut');
// 21a. It is one undo step, not one per pointer move.
await undo();
await sleep(400);
check(
'undo removes the frame in one step',
(await json(
`((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).length`,
)) === 0,
);
await t.redo();
await sleep(400);
// 22. A sprite wearing the frame shows that part of the sheet and nothing else. This is the
// check the whole feature exists for: the model difference between "whole picture" and
// "this frame" is one id, so only the drawn pixels can tell them apart.
// The sprite from the texture step is reused, white and unparented, but its texture
// reference was left dangling there – it is pointed at the frame here.
await evaluate(`(() => {
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.color = 0xffffff;
sprite.props.texture = ${JSON.stringify(frame.id)};
__gm.state.preview();
})()`);
await evaluate("__gm.showTab('scene')");
await sleep(900);
const box = await json("__gm.objectRect('otex')");
check('the textured sprite is on screen again', Boolean(box) && box.w > 8, JSON.stringify(box));
if (box) {
const at = async (fx, fy) =>
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
const corners = [await at(-0.3, -0.3), await at(0.3, -0.3), await at(-0.3, 0.3), await at(0.3, 0.3)];
const read = corners.map(nearestColour);
// Without the frame these four corners read red / green / blue / white – that is what the
// texture step asserts one screen earlier. Reading green four times is therefore the exact
// statement that the fill was confined to one quadrant of the sheet.
check(
'the whole sprite shows the one quadrant the frame cuts out',
read.every((c) => c === 'green'),
JSON.stringify({ corners, read }),
);
await shot('35-sprite-wearing-a-frame');
}
// 23. Slicing offers a live count before it commits – the readout is what makes the numbers
// answerable without undoing.
// Repainted magenta before slicing: with the frame gone the sprite falls back to this, and
// magenta is nowhere in the sheet, so the fallback cannot be confused with a part of it.
await evaluate(`(() => {
__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff;
__gm.state.preview();
})()`);
await evaluate("__gm.showTab('texture')");
await sleep(400);
await clickText('Slice…');
await sleep(500);
const dialog = await json('__gm.overlayTexts()');
check(
'the slice dialog states what the numbers would produce',
dialog.some((text) => /^\d+ frames of \d+ × \d+ px$/.test(text)),
JSON.stringify(dialog),
);
check(
'and names the picture size it is cutting',
dialog.some((text) => text === 'Picture is 64 × 48 px'),
JSON.stringify(dialog),
);
await clickText('Slice');
await sleep(700);
const sliced = await json(
`((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).map((f) => f.name + ' ' + f.w + 'x' + f.h)`,
);
check(
'slicing 4x4 replaces the frames with sixteen 16x12 cells',
sliced.length === 16 && sliced.every((s) => s.endsWith('16x12')),
JSON.stringify(sliced.slice(0, 3)),
);
check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0]));
await shot('36-sliced-sheet');
// 23a. The sprite pointed at the frame that slicing removed falls back to its colour rather
// than to a wrong part of the picture.
await evaluate("__gm.showTab('scene')");
await sleep(700);
if (box) {
const fallback = await json(
`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`,
);
check(
'a sprite whose frame was sliced away falls back to its plain colour',
nearestColour(fallback) === 'plain',
JSON.stringify({ fallback, read: nearestColour(fallback) }),
);
}
}
+2
View File
@@ -15,6 +15,7 @@ import { containers } from './09-containers.mjs';
import { textAndEnabled } from './10-text-and-enabled.mjs';
import { menus } from './11-menus.mjs';
import { textures } from './12-textures.mjs';
import { frames } from './13-frames.mjs';
export const STEPS = [
hierarchy,
@@ -29,4 +30,5 @@ export const STEPS = [
textAndEnabled,
menus,
textures,
frames,
];