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