Add a Texture tab for defining frames

A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets
frames be drawn on it: drag to create, move, resize via eight handles, edit
name and rect in the inspector, Delete to remove. Every change is a command,
so one gesture is one undo step. "Slice..." cuts a grid from columns and rows
with optional offset and spacing, previewing the resulting frame count and
cell size live.

Sprites pick a frame instead of the whole texture through the same `texture`
property: it now holds either a texture id or a frame id. Frame ids come from
the same generator and are document-wide unique, so one picker, one property
and one set of instance overrides cover both cases, and every sprite written
before this still means the same thing.

A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image,
because it derives its uvs from the shape bounds and the inverted style
matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which
keeps a sheet at a single GPU upload no matter how many of its frames are on
screen at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 14:27:23 +02:00
1 parent 5a6af74e44
commit 35a1a23af8
29 files changed
+2650 -53

No files matched your search

+11 -2
View File
@@ -22,7 +22,7 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
const base = { id, name, props: defaultsFor(type) };
switch (type) {
case 'texture':
return { ...base, type: 'texture', file: '', width: 0, height: 0 };
return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id);
case 'scene':
return { ...base, type: 'scene', objects: [] };
case 'camera':
@@ -70,6 +70,15 @@ export interface TextureImage {
height: number;
}
/**
* A texture asset for a stored image, without frames – slicing it is a separate, undoable
* edit. Kept apart from `buildAsset` so callers that need a `TextureAsset` get one, instead
* of the `Asset` union they would have to narrow again.
*/
export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
return { id, name, props: defaultsFor('texture'), type: 'texture', ...image, frames: [] };
}
/**
* Adds a texture asset for an image that is already stored on the server. Uploading is I/O and
* therefore happens before the command: this one only records the result, so undo and redo of
@@ -80,7 +89,7 @@ export class CreateTextureCommand implements Command {
readonly asset: TextureAsset;
constructor(name: string, image: TextureImage) {
this.asset = { ...buildAsset('texture', name), type: 'texture', ...image };
this.asset = buildTextureAsset(name, image);
this.label = `Import texture "${name}"`;
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Undoable edits of a texture's frames – the named rectangles a sprite sheet is cut into
* (see `model/texture.ts`). Every rectangle that enters the model passes `normalizeFrameRect`
* here rather than at the call site: frames come from dragging in a zoomed view, so callers
* produce fractional and inverted rectangles as a matter of course.
*/
import { newId } from '../ids';
import type { TextureAsset, TextureFrame } from '../model/Asset';
import type { Project } from '../model/Project';
import {
frameRect,
type FrameRect,
nextFrameNames,
normalizeFrameRect,
setFrameRect,
sliceFrames,
type SliceOptions,
uniqueFrameName,
} from '../model/texture';
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset } from './require';
/** The name new frames are derived from when the caller has nothing better to offer. */
const DEFAULT_NAME = 'Frame';
/** The asset as a texture, rejecting both "no such asset" and "that one is not a picture". */
function requireTexture(project: Project, assetId: string): TextureAsset {
const asset = requireAsset(project, assetId);
if (asset.type !== 'texture') throw new CommandError(`${asset.name} is not a texture`);
return asset;
}
function requireFrame(asset: TextureAsset, frameId: string): TextureFrame {
const frame = asset.frames.find((f) => f.id === frameId);
if (!frame) throw new CommandError(`Frame ${frameId} not found in ${asset.name}`);
return frame;
}
/**
* Adds one frame to a texture. The id is known before execution, which is what lets the caller
* select the new frame right after executing – the same trick `PlaceAssetCommand` uses.
*/
export class AddFrameCommand implements Command {
readonly label = 'Add frame';
readonly frameId = newId();
constructor(
private readonly assetId: string,
private readonly rect: FrameRect,
private readonly name = DEFAULT_NAME,
) {}
execute(project: Project): void {
const asset = requireTexture(project, this.assetId);
asset.frames.push({
id: this.frameId,
// Resolved on every execution rather than in the constructor: a redo has to fit the
// names that exist when it runs, not the ones that existed when the command was built.
name: uniqueFrameName(asset, this.name),
...normalizeFrameRect(this.rect, asset.width, asset.height),
});
}
undo(project: Project): void {
const asset = requireTexture(project, this.assetId);
asset.frames = removeById(asset.frames, this.frameId);
}
}
/**
* Moves and resizes a frame – one command per drag, not one per pointer move.
*
* A drag previews itself by writing into the frame directly; on release the caller puts the
* starting rectangle back and executes this once, exactly as `SceneViewport` does for
* `MoveSceneObjectCommand`. The rectangle to revert to is therefore read from the model at
* execution time: a caller that forgets to undo its preview would record the preview as the
* state to return to, and the whole drag would collapse into one undo step of nothing.
*/
export class SetFrameRectCommand implements Command {
readonly label = 'Move frame';
private previous: FrameRect = { x: 0, y: 0, w: 0, h: 0 };
constructor(
private readonly assetId: string,
private readonly frameId: string,
private readonly rect: FrameRect,
) {}
execute(project: Project): void {
const asset = requireTexture(project, this.assetId);
const frame = requireFrame(asset, this.frameId);
this.previous = frameRect(frame);
setFrameRect(frame, normalizeFrameRect(this.rect, asset.width, asset.height));
}
undo(project: Project): void {
const asset = requireTexture(project, this.assetId);
// Restored verbatim instead of normalized again: undo owes the user the state that was
// there, even if it came out of a document written before the image was measured.
setFrameRect(requireFrame(asset, this.frameId), this.previous);
}
}
/** Renames a frame. The name is what the sprite's texture picker shows, so it may not be blank. */
export class RenameFrameCommand implements Command {
readonly label = 'Rename frame';
private previous = '';
constructor(
private readonly assetId: string,
private readonly frameId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const asset = requireTexture(project, this.assetId);
const frame = requireFrame(asset, this.frameId);
const name = this.name.trim();
if (!name) throw new CommandError('A frame needs a name');
this.previous = frame.name;
frame.name = uniqueFrameName(asset, name, this.frameId);
}
undo(project: Project): void {
requireFrame(requireTexture(project, this.assetId), this.frameId).name = this.previous;
}
}
/**
* Removes a frame, putting it back at its former index on undo – the frame order is the order
* of the sheet, so appending it again would silently reshuffle the picture.
*
* Sprites wearing the frame are left pointing at it. A dangling reference reads as "no
* picture" everywhere (`resolveTexture`) and comes back by itself when this is undone, which
* clearing the references could not offer.
*/
export class RemoveFrameCommand implements Command {
readonly label = 'Remove frame';
private removed?: Removed<TextureFrame>;
constructor(
private readonly assetId: string,
private readonly frameId: string,
) {}
execute(project: Project): void {
const asset = requireTexture(project, this.assetId);
const removed = removeFirst(asset.frames, (f) => f.id === this.frameId);
if (!removed) throw new CommandError(`Frame ${this.frameId} not found in ${asset.name}`);
this.removed = removed;
}
undo(project: Project): void {
if (!this.removed) return;
restoreAt(requireTexture(project, this.assetId).frames, [this.removed]);
}
}
/**
* Cuts a sheet into a grid of frames (see `sliceFrames`), either adding them to the frames
* already there or replacing those.
*
* A grid that yields nothing is rejected rather than applied as an empty change: it means the
* image has not been measured yet or the cells came out smaller than a pixel, and both look to
* the user like the Slice button doing nothing at all.
*/
export class SliceFramesCommand implements Command {
readonly label: string;
private created: TextureFrame[] = [];
private previous: TextureFrame[] = [];
constructor(
private readonly assetId: string,
private readonly options: SliceOptions,
private readonly replace: boolean,
) {
this.label = replace ? 'Re-slice frames' : 'Slice frames';
}
execute(project: Project): void {
const asset = requireTexture(project, this.assetId);
if (asset.width <= 0 || asset.height <= 0)
throw new CommandError(`The size of ${asset.name} is not known, so it cannot be sliced`);
const rects = sliceFrames(asset.width, asset.height, this.options);
if (rects.length === 0)
throw new CommandError(`${this.options.columns}×${this.options.rows} does not fit ${asset.name}`);
this.previous = [...asset.frames];
if (this.replace) asset.frames = [];
// A redo reuses the frames the first run created: while the command sat on the undo stack
// a sprite may have been pointed at one of them, and a fresh id would break that.
if (this.created.length === 0) this.created = this.buildFrames(asset, rects);
else asset.frames.push(...this.created);
}
undo(project: Project): void {
requireTexture(project, this.assetId).frames = this.previous;
}
/**
* Frames named after their position in the sheet ("Frame 1" is the first cell in reading
* order). The whole run of names is asked for at once, so that keeping the existing frames
* shifts the numbering past them instead of suffixing a number onto a number.
*/
private buildFrames(asset: TextureAsset, rects: readonly FrameRect[]): TextureFrame[] {
const names = nextFrameNames(asset, rects.length, DEFAULT_NAME);
const created = rects.map((rect, index) => ({
id: newId(),
name: names[index]!,
// A no-op for a whole-pixel grid, but the offsets come from a form and may be anything.
...normalizeFrameRect(rect, asset.width, asset.height),
}));
asset.frames.push(...created);
return created;
}
}
+24 -2
View File
@@ -65,6 +65,22 @@ export interface ContainerAsset extends AssetBase {
objects: SceneObject[];
}
/**
* One named rectangle of a texture, in image pixels – a frame of a sprite sheet.
*
* Its `id` is drawn from the same generator as asset ids and is unique across the document,
* which is what lets a sprite's `texture` property name either a whole texture or one frame
* of it (see `model/texture.ts`).
*/
export interface TextureFrame {
id: string;
name: string;
x: number;
y: number;
w: number;
h: number;
}
/**
* An uploaded image. `file` names it in `data/textures/` and is content-addressed
* (`{sha256}.{ext}`), so the same picture uploaded twice is one file and a stored file can be
@@ -75,15 +91,21 @@ export interface ContainerAsset extends AssetBase {
* is the reference to it – sprites carry `texture` (see PropertySchema), which is how
* instance overrides, "Apply to container" and `Set property` work on it for free.
*
* `frames` cuts the picture into named rectangles (the Texture tab draws them); a texture
* with no frames is simply used whole, which is what every single-image texture wants and
* what every document written before frames existed already says.
*
* `width`/`height` are the pixel size as uploaded, kept so a sprite can be sized to its
* picture without waiting for the image to load. An empty `file` means the upload failed or
* has not happened yet; such a texture draws as nothing.
* picture without waiting for the image to load – and so frames can be validated and sliced
* before it arrives. An empty `file` means the upload failed or has not happened yet; such a
* texture draws as nothing.
*/
export interface TextureAsset extends AssetBase {
type: 'texture';
file: string;
width: number;
height: number;
frames: TextureFrame[];
}
export type Asset =
+18 -10
View File
@@ -71,32 +71,40 @@ export type PropertyUsage =
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
/**
* Every property that references `assetId`, on assets and on the overrides of placed objects.
* Every property that references one of `ids`, on assets and on the overrides of placed
* objects.
*
* This is the other half of "where is this asset used": `findUsages` finds assets that were
* *placed*, which is meaningless for a texture – a texture is never placed, it is worn by
* sprites through their `texture` property. Deleting one therefore has to count both, or it
* reports "0 uses" for a picture that ten sprites are showing.
*
* A reference is deliberately left dangling rather than cleared when its asset goes: a
* Several ids rather than one because a texture is referenced by more than its own id: every
* frame cut from it is a reference to the same picture (see `textureRefIds`). Deleting the
* texture has to count the sprites wearing a frame of it too, or it under-reports exactly the
* sheets that were sliced – the ones with the most users.
*
* A reference is deliberately left dangling rather than cleared when its target goes: a
* dangling one resolves to "no picture" everywhere it is read, and it comes back by itself
* when the delete is undone.
*/
export function findPropertyUsages(project: Project, assetId: string): PropertyUsage[] {
if (!assetId) return [];
export function findPropertyUsages(project: Project, ids: string | readonly string[]): PropertyUsage[] {
const wanted = new Set((typeof ids === 'string' ? [ids] : ids).filter(Boolean));
if (wanted.size === 0) return [];
const result: PropertyUsage[] = [];
const assetKeys = new Map<string, string[]>();
const refKeys = new Map<string, string[]>();
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
for (const asset of project.assets) {
const keys = schemaForAsset(asset)
.filter((def) => def.kind === 'asset')
.filter((def) => def.kind === 'asset' || def.kind === 'texture')
.map((def) => def.key);
assetKeys.set(asset.id, keys);
for (const key of keys) if (asset.props[key] === assetId) result.push({ on: 'asset', asset, key });
refKeys.set(asset.id, keys);
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
}
for (const holder of objectHolders(project)) {
for (const object of holder.objects) {
for (const key of assetKeys.get(object.assetId) ?? [])
if (object.overrides[key] === assetId) result.push({ on: 'object', holder, object, key });
for (const key of refKeys.get(object.assetId) ?? [])
if (matches(object.overrides[key])) result.push({ on: 'object', holder, object, key });
}
}
return result;
+205 -6
View File
@@ -1,3 +1,4 @@
import type { TextureAsset, TextureFrame } from './Asset';
import type { PropertyBag } from './Properties';
import { findAsset, type Project } from './Project';
@@ -12,23 +13,221 @@ export function isTextureFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/** A picture ready to be drawn: which file, and whether to smooth it when scaled. */
/** A rectangle in image pixels: the part of a texture a frame cuts out. */
export interface FrameRect {
x: number;
y: number;
w: number;
h: number;
}
/** Smallest frame that is still a picture rather than a slip of the mouse. */
export const MIN_FRAME_SIZE = 1;
/**
* A picture ready to be drawn: which file, whether to smooth it when scaled, and which part
* of it. `rect` is null for the whole image – not a rectangle covering everything, because
* the renderer can then skip the texture matrix entirely (Pixi fits an unmatrixed fill to the
* shape by itself) and because "the whole picture" survives the image being re-uploaded at a
* different size.
*/
export interface TextureRef {
file: string;
/** False for pixel art, where interpolation is the difference between crisp and mush. */
smooth: boolean;
/** Sub-rectangle in image pixels, or null for the whole image. */
rect: FrameRect | null;
/** Size of the whole image in pixels – the reference frame `rect` is expressed in. */
sourceWidth: number;
sourceHeight: number;
}
/** A texture asset together with one of its frames. */
export interface FrameRef {
asset: TextureAsset;
frame: TextureFrame;
}
/**
* The frame with that id, wherever in the project it lives.
*
* Frame ids come from the same generator as asset ids and are unique across the document,
* which is what lets a sprite's `texture` property hold either kind: an asset id means the
* whole picture, a frame id means one cut-out of it. One property, one picker, and every
* sprite written before frames existed keeps meaning exactly what it meant.
*/
export function findFrame(project: Project, frameId: string): FrameRef | null {
if (!frameId) return null;
for (const asset of project.assets) {
if (asset.type !== 'texture') continue;
const frame = asset.frames.find((f) => f.id === frameId);
if (frame) return { asset, frame };
}
return null;
}
/** Every id that means "this picture": the texture asset itself and each of its frames. */
export function textureRefIds(asset: TextureAsset): string[] {
return [asset.id, ...asset.frames.map((f) => f.id)];
}
/**
* The picture a bag of properties points at, or null when it points at nothing usable –
* no reference, a dangling one (the texture was deleted), or one whose upload never
* finished. Callers draw their plain colour in that case, which is what a sprite looked
* like before textures existed.
* no reference, a dangling one (the texture or the frame was deleted), or one whose upload
* never finished. Callers draw their plain colour in that case, which is what a sprite
* looked like before textures existed.
*/
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
const id = String(props.texture ?? '');
if (!id) return null;
const asset = findAsset(project, id);
if (asset?.type !== 'texture' || !isTextureFile(asset.file)) return null;
return { file: asset.file, smooth: String(asset.props.filter ?? 'linear') !== 'nearest' };
if (asset) return asset.type === 'texture' ? refFor(asset, null) : null;
const found = findFrame(project, id);
return found ? refFor(found.asset, found.frame) : null;
}
function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null {
if (!isTextureFile(asset.file)) return null;
return {
file: asset.file,
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
sourceWidth: asset.width,
sourceHeight: asset.height,
};
}
/**
* A rectangle rounded to whole pixels and forced inside the image.
*
* Frames are pixel coordinates into a bitmap, so a fractional one has no meaning – it would
* sample half of a neighbouring pixel along every edge, which on a sprite sheet is the
* classic seam of foreign colour. Dragging produces fractions constantly (the view is
* zoomed), so this is applied on the way into the model rather than trusted from callers.
*/
export function normalizeFrameRect(rect: FrameRect, imageWidth: number, imageHeight: number): FrameRect {
// An image of unknown size (upload still running) cannot constrain anything; only round.
const limitX = imageWidth > 0 ? imageWidth : Infinity;
const limitY = imageHeight > 0 ? imageHeight : Infinity;
const x0 = clampInt(Math.min(rect.x, rect.x + rect.w), 0, limitX - MIN_FRAME_SIZE);
const y0 = clampInt(Math.min(rect.y, rect.y + rect.h), 0, limitY - MIN_FRAME_SIZE);
const x1 = clampInt(Math.max(rect.x, rect.x + rect.w), x0 + MIN_FRAME_SIZE, limitX);
const y1 = clampInt(Math.max(rect.y, rect.y + rect.h), y0 + MIN_FRAME_SIZE, limitY);
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function clampInt(value: number, min: number, max: number): number {
const n = Math.round(Number.isFinite(value) ? value : 0);
return Math.max(min, Math.min(max, n));
}
/** How a sheet is cut into a regular grid – the shape of nearly every sprite sheet. */
export interface SliceOptions {
columns: number;
rows: number;
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
offsetX: number;
offsetY: number;
/** Pixels between neighbouring cells (sheets exported with padding). */
spacingX: number;
spacingY: number;
}
export const DEFAULT_SLICE: SliceOptions = {
columns: 4,
rows: 4,
offsetX: 0,
offsetY: 0,
spacingX: 0,
spacingY: 0,
};
/**
* The rectangles a grid cuts a sheet into, in reading order (left to right, top to bottom) –
* which is the order sprite sheets are authored in and therefore the order an animation will
* want them.
*
* Cell size is derived from the image rather than asked for: everyone knows how many tiles
* their sheet has, almost nobody knows the pixel size off-hand, and deriving it cannot
* disagree with the picture. A cell that would reach past the image is dropped instead of
* clipped – a clipped one looks like a frame and is not one.
*/
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
const columns = Math.floor(options.columns);
const rows = Math.floor(options.rows);
// NaN has to be rejected explicitly: every comparison against it is false, so a NaN column
// count would pass `columns < 1` and only produce nothing by accident of the loop condition.
if (!Number.isFinite(columns) || !Number.isFinite(rows)) return [];
if (!Object.values(options).every((value) => Number.isFinite(value))) return [];
if (columns < 1 || rows < 1 || imageWidth <= 0 || imageHeight <= 0) return [];
const cellW = Math.floor((imageWidth - options.offsetX - options.spacingX * (columns - 1)) / columns);
const cellH = Math.floor((imageHeight - options.offsetY - options.spacingY * (rows - 1)) / rows);
if (cellW < MIN_FRAME_SIZE || cellH < MIN_FRAME_SIZE) return [];
const rects: FrameRect[] = [];
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
const x = options.offsetX + column * (cellW + options.spacingX);
const y = options.offsetY + row * (cellH + options.spacingY);
if (x + cellW > imageWidth || y + cellH > imageHeight) continue;
rects.push({ x, y, w: cellW, h: cellH });
}
}
return rects;
}
/** The rectangle part of a frame, and the way to write one back – the only glue between the two. */
export function frameRect(frame: TextureFrame): FrameRect {
return { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
}
export function setFrameRect(frame: TextureFrame, rect: FrameRect): void {
frame.x = rect.x;
frame.y = rect.y;
frame.w = rect.w;
frame.h = rect.h;
}
/**
* A frame name that is free within its texture: `base`, else "base 2", "base 3" … – the same
* shape `nextAssetName` produces, because a name the user reads should not be numbered by two
* conventions in one document.
*
* Names matter more here than they look: they are what the sprite's picker shows, so two
* frames called "idle" would be one choice repeated rather than two.
*/
export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''): string {
const taken = takenFrameNames(asset, ignoreId);
const stem = base || 'Frame';
return taken.has(stem) ? firstFreeName(taken, stem, 2) : stem;
}
/**
* `count` free names numbered from 1 – what slicing a sheet wants, where the number is the
* cell's position and "Frame 1" next to "Frame 2" is the whole point.
*
* Numbers already taken are skipped rather than suffixed again: re-slicing a sheet that keeps
* its old frames must not produce "Frame 2 1".
*/
export function nextFrameNames(asset: TextureAsset, count: number, base = 'Frame'): string[] {
const taken = takenFrameNames(asset);
const names: string[] = [];
let n = 1;
for (let i = 0; i < count; i++) {
const name = firstFreeName(taken, base, n);
taken.add(name);
names.push(name);
n = Number(name.slice(base.length + 1)) + 1;
}
return names;
}
function takenFrameNames(asset: TextureAsset, ignoreId = ''): Set<string> {
return new Set(asset.frames.filter((f) => f.id !== ignoreId).map((f) => f.name));
}
function firstFreeName(taken: ReadonlySet<string>, base: string, from: number): string {
for (let i = from; ; i++) {
const candidate = `${base} ${i}`;
if (!taken.has(candidate)) return candidate;
}
}
+14 -4
View File
@@ -23,7 +23,16 @@ export type PropertyDef =
* The id is not validated on load – a reference can dangle, exactly like a graph node's
* asset parameter, and the places that read one have to cope with that.
*/
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] };
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
/**
* A picture: the id of a texture asset (the whole image) or of one of its frames. Its own
* kind rather than an `asset` reference with `assetTypes: ['texture']`, because a frame is
* not an asset – it is a rectangle inside one, and the picker has to offer both.
*
* Like an asset reference the id is not validated on load and may dangle; everything that
* reads one falls back to drawing no picture.
*/
| { key: string; label: string; kind: 'texture'; default: string };
export type PropertySchema = readonly PropertyDef[];
@@ -69,7 +78,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
* white coin picture becomes ten differently coloured coins, per instance, for free.
*/
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'texture', label: 'Texture', kind: 'asset', default: '', assetTypes: ['texture'] },
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property:
// only how it is sampled is worth deciding, and that decision is the whole schema.
@@ -190,11 +199,12 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
}
case 'boolean':
return Boolean(value);
// An asset reference is an id like the other two are plain strings; whether it still
// resolves is not decidable from a single property definition.
// An asset or texture reference is an id like the other two are plain strings; whether it
// still resolves is not decidable from a single property definition.
case 'text':
case 'key':
case 'asset':
case 'texture':
return String(value);
case 'enum': {
const s = String(value);
+68
View File
@@ -0,0 +1,68 @@
import type { TextureAsset, TextureFrame } from '../model/Asset';
import { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture';
/** Shown for a `texture` property that references nothing. */
export const NO_TEXTURE_LABEL = 'None';
/** Separates a texture's name from a frame's name wherever the two are shown together. */
const FRAME_SEPARATOR = ' / ';
/** One thing a sprite's `texture` property can be set to. */
export interface TextureChoice {
/** Asset id for the whole picture, frame id for one cut-out of it. */
id: string;
label: string;
/** The texture this choice belongs to – frames of one sheet stay together in the menu. */
asset: TextureAsset;
/** Null for the whole-picture entry. */
frame: TextureFrame | null;
}
/**
* How a texture reference reads wherever it is displayed: the texture's name for a whole
* picture, "Sheet / Frame" for a frame, "None" for the empty id, and `<id>?` when the id no
* longer resolves.
*
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
* only trace left of what was meant, and blanking it would make a deleted frame look like a
* field that was never filled in. `project` may be null while a view has no project yet.
*/
export function textureRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_TEXTURE_LABEL;
if (!project) return `${id}?`;
const asset = findAsset(project, id);
if (asset) return asset.type === 'texture' ? asset.name : `${id}?`;
const found = findFrame(project, id);
return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`;
}
/**
* Everything a sprite may wear, in menu order: each texture followed by its own frames.
*
* The whole picture stays on offer next to the frames rather than being replaced by them.
* A texture that was never sliced has no frames at all, and a sheet's full image is
* occasionally what you want (a background, a check of the sheet itself) – forcing a frame
* would mean slicing every single-image texture into one pointless frame.
*
* Textures are sorted by name so the order does not depend on upload time; frames keep the
* order they were created in, which for a sliced sheet is reading order and therefore the
* order the animation runs in.
*/
export function textureRefChoices(project: Project): TextureChoice[] {
const textures = project.assets
.filter((a): a is TextureAsset => a.type === 'texture')
.sort((a, b) => a.name.localeCompare(b.name));
const choices: TextureChoice[] = [];
for (const asset of textures) {
choices.push({ id: asset.id, label: asset.name, asset, frame: null });
for (const frame of asset.frames)
choices.push({
id: frame.id,
label: `${asset.name}${FRAME_SEPARATOR}${frame.name}`,
asset,
frame,
});
}
return choices;
}
+28 -6
View File
@@ -1,11 +1,11 @@
// The graph package index registers the built-in nodes; unknown node types are dropped below.
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
import type { Asset } from '../model/Asset';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile } from '../model/texture';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
import { hasParentCycle } from '../model/walk';
import {
coerceValue,
@@ -43,8 +43,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
}
}
// Pass 2: build assets.
// Pass 2: build assets. Frame ids live in the same reference space as asset ids (a sprite's
// `texture` may name either), so they are handed the ids already spoken for.
const assets: Asset[] = [];
const takenIds = new Set(schemas.keys());
for (const a of rawAssets) {
const id = typeof a.id === 'string' ? a.id : '';
const schema = schemas.get(id);
@@ -64,7 +66,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
graph: normalizeGraph(a.graph),
});
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a) });
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} else {
assets.push({ id, type, name, props });
}
@@ -189,11 +191,31 @@ function normalizeGraph(raw: unknown): Graph {
* is dropped rather than kept: it can only have been hand-written, and a request built from it
* would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour.
*/
function normalizeTexture(raw: Record<string, unknown>): { file: string; width: number; height: number } {
function normalizeTexture(
raw: Record<string, unknown>,
takenIds: Set<string>,
): { file: string; width: number; height: number; frames: TextureFrame[] } {
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
const size = (value: unknown): number =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
return { file, width: size(raw.width), height: size(raw.height) };
const width = size(raw.width);
const height = size(raw.height);
const frames: TextureFrame[] = [];
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
const f = asRecord(item);
const id = typeof f.id === 'string' ? f.id : '';
// A frame id shares the reference space with asset ids (a sprite may name either), so a
// duplicate would make one reference mean two pictures. Drop it rather than resolve it.
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const rect = normalizeFrameRect(
{ x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) },
width,
height,
);
frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect });
}
return { file, width, height, frames };
}
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
+25 -6
View File
@@ -14,9 +14,13 @@ import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
import { ProjectSession } from './ProjectSession';
import { installShortcuts } from './shortcuts';
import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
export type CentreTab = 'scene' | 'graph' | 'game';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
/** Tab strip order – also the one list `showTab` walks, so a new tab cannot be half-added. */
const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
@@ -34,6 +38,8 @@ export class Editor implements PlayHost {
/** Game tab: the running world through its camera. */
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
/** Texture tab: one sheet with its frames. */
readonly textureEditor: TextureEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
@@ -63,6 +69,7 @@ export class Editor implements PlayHost {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.textureEditor = new TextureEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
@@ -89,7 +96,7 @@ export class Editor implements PlayHost {
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor.
// Centre: a tab strip above the scene view, the game view, the graph editor or the sheet editor.
this.centre = new VStack();
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
tabs.minHeight = Theme.controlHeight + 8;
@@ -97,14 +104,21 @@ export class Editor implements PlayHost {
scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }),
game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }),
graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }),
texture: new Button('Texture', {
variant: 'ghost',
width: 90,
onClick: () => this.showTab('texture'),
}),
};
for (const tab of ['scene', 'game', 'graph'] as const) tabs.add(this.tabButtons[tab]);
for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]);
this.centre.add(tabs);
this.centre.add(this.viewport, 1);
this.centre.add(this.graphEditor, 1);
this.centre.add(this.gameView, 1);
this.centre.add(this.textureEditor, 1);
this.graphEditor.visible = false;
this.gameView.visible = false;
this.textureEditor.visible = false;
this.tabButtons.scene.setToggled(true);
this.leftColumn.minWidth = this.leftWidth;
@@ -130,9 +144,13 @@ export class Editor implements PlayHost {
attachAutosave(state, store);
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// Selecting a graph node switches to the graph; selecting a behavior does not force it.
// Selecting a graph node switches to the graph, selecting a frame to the sheet: both are
// things that exist *only* in their tab, so a selection you could not see would be a lie.
// Selecting the behavior or the texture asset itself does not force anything.
state.on('selection', () => {
if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
const kind = state.selection?.kind;
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
});
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
state.on('project', () => this.play.syncFromModel());
@@ -143,7 +161,8 @@ export class Editor implements PlayHost {
this.viewport.visible = tab === 'scene';
this.graphEditor.visible = tab === 'graph';
this.gameView.visible = tab === 'game';
for (const [key, button] of Object.entries(this.tabButtons)) button.setToggled(key === tab);
this.textureEditor.visible = tab === 'texture';
for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab);
this.centre.relayout();
ui().focus.validate();
}
+5 -2
View File
@@ -7,6 +7,7 @@ import { AddColliderCommand } from '../core/commands/colliderCommands';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { findAsset, findPropertyUsages, findUsages } from '../core/model/Project';
import { textureRefIds } from '../core/model/texture';
import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
@@ -41,8 +42,10 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
if (!asset) return;
const usages = findUsages(state.project, assetId).length;
// A texture is never placed – it is worn through a property – so counting placed instances
// would report "unused" for a picture a dozen sprites are showing.
const references = findPropertyUsages(state.project, assetId).length;
// would report "unused" for a picture a dozen sprites are showing. Its frames count as the
// texture itself: a sprite wearing one of them loses its picture just the same.
const ids = asset.type === 'texture' ? textureRefIds(asset) : assetId;
const references = findPropertyUsages(state.project, ids).length;
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
+12 -3
View File
@@ -1,6 +1,6 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
import { ui, Widget } from '../ui';
import type { Editor } from './Editor';
import type { CentreTab, Editor } from './Editor';
import type { EditorState } from './state';
/**
@@ -8,7 +8,7 @@ import type { EditorState } from './state';
* visible text and to read editor state. Read-only access; harmless in production.
*/
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
const { viewport, gameView, graphEditor, hierarchy } = editor;
const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -81,6 +81,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
center: () => centreOf(graphEditor),
},
texture: {
current: () => textureEditor.currentAssetId,
/**
* Where an image pixel of the shown sheet sits on screen. Frames are dragged in image
* coordinates, so without this a check could only drag somewhere and hope – it could not
* aim at a known part of the picture and then assert which part came out.
*/
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
@@ -103,6 +112,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
},
fps: () => editor.fps,
tab: () => editor.tab,
showTab: (tab: 'scene' | 'graph' | 'game') => editor.showTab(tab),
showTab: (tab: CentreTab) => editor.showTab(tab),
};
}
+8 -2
View File
@@ -2,6 +2,7 @@ import type { Container } from 'pixi.js';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import {
@@ -13,7 +14,8 @@ import {
/**
* Shows the properties of the current selection. The rows are generated from the
* PropertySchema by one section per selection kind (asset, scene object, graph node); the
* PropertySchema by one section per selection kind (asset, scene object, graph node, texture
* frame); the
* panel only decides when a section has to be rebuilt and when its values merely refresh.
*/
export class Inspector extends Panel {
@@ -70,6 +72,7 @@ export class Inspector extends Panel {
if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' }));
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds);
else buildObjectSection(this.host, s.sceneId, s.objectId);
this.scroll.setContent(this.host.body);
@@ -80,6 +83,7 @@ export class Inspector extends Panel {
if (!s) return '';
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId);
}
@@ -96,7 +100,9 @@ export class Inspector extends Panel {
? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node'
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
if (!ok) this.rebuild();
}
}
+31
View File
@@ -7,6 +7,7 @@ import {
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
Checkbox,
@@ -228,6 +229,36 @@ export function createField(
},
};
}
case 'texture': {
// The id is kept here for the same reason as in the asset case, and more urgently: a
// frame's label repeats its sheet's name, so several entries can read identically.
let current = String(value);
const button = new Dropdown(textureRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') },
// Built on click, not once per row: textures are uploaded and frames are cut, renamed
// and sliced away while the inspector stands, and the picker has to show what exists now.
...textureRefChoices(ctx.project()).map((choice) => ({
label: mark(choice.id, choice.label),
onSelect: () => handlers.onChange(choice.id),
})),
];
// Searchable: one sliced sheet alone contributes an entry per frame. The entries read
// "Sheet / Frame", so the sheet's own name gathers its frames into one block of the
// list and typing either name finds them.
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = String(v);
button.setText(textureRefLabel(ctx.project(), current));
},
};
}
}
}
+136
View File
@@ -0,0 +1,136 @@
import {
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
} from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
import { Button, TextField } from '../../ui';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
/** The components of the rectangle, in the order they are shown. */
const RECT_COMPONENTS = [
{ key: 'x', label: 'X' },
{ key: 'y', label: 'Y' },
{ key: 'w', label: 'Width' },
{ key: 'h', label: 'Height' },
] as const;
/**
* Structure-defining facts of a frame selection: none. A frame is a name and four numbers
* whatever texture it was cut from, so the rows never differ – only the frame disappearing
* can force a rebuild, and that is what `refreshFrameSection` reports.
*/
export function frameSignature(project: Project, assetId: string, frameId: string): string {
return currentFrame(project, assetId, frameId) ? 'frame' : 'missing';
}
/** Name, the rectangle it cuts out of its texture, and the way to delete it. */
export function buildFrameSection(host: InspectorHost, assetId: string, frameId: string): void {
const asset = currentTexture(host.state.project, assetId);
const frame = asset?.frames.find((f) => f.id === frameId);
if (!asset || !frame) return;
addTitle(host, 'TEXTURE FRAME');
host.nameField = new TextField(frame.name, {
onChange: (name) => {
// Like an asset name. The command rejects a blank name by itself; what it cannot do is
// put the old text back into the field, which is why an empty one is not sent at all.
if (name.trim()) host.state.execute(new RenameFrameCommand(assetId, frameId, name.trim()));
else host.nameField?.setValue(frame.name);
},
});
const { row } = labelledRow('Name');
row.add(host.nameField, 1);
host.body.add(row);
addNote(host, `Frame of the texture "${asset.name}".`);
addTitle(host, 'RECTANGLE (IMAGE PIXELS)');
for (const component of RECT_COMPONENTS)
rectRow(host, assetId, frameId, component.key, component.label, frame[component.key]);
addGap(host);
host.body.add(
new Button('Delete frame', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveFrameCommand(assetId, frameId)),
}),
);
}
/** Pushes the frame's current name and rectangle into the rows. False when it is gone (rebuild). */
export function refreshFrameSection(host: InspectorHost, assetId: string, frameId: string): boolean {
const frame = currentFrame(host.state.project, assetId, frameId);
if (!frame) return false;
host.nameField?.setValue(frame.name);
for (const component of RECT_COMPONENTS) host.fields.get(component.key)?.setValue(frame[component.key]);
return true;
}
/** A number row editing one component of the rectangle. Whole pixels: a frame indexes a bitmap. */
function rectRow(
host: InspectorHost,
assetId: string,
frameId: string,
key: keyof FrameRect,
label: string,
value: number,
): void {
const row = createFieldRow(
host,
{ key, label, kind: 'number', default: 0, step: 1 },
value,
rectHandlers(host, assetId, frameId, key),
);
host.fields.set(key, row);
host.body.add(row.row);
}
/**
* The whole rectangle with one component replaced; the other three are read from the model at
* that moment, so a frame dragged in the Texture tab in between is not undone by typing into a
* field beside it.
*
* Normalising here as well as in the command is not a duplicate rule but the same one applied
* to the *preview*: an un-normalised preview would draw the frame off the sheet on the way
* through a scrub, and a half-typed "1" of "16" would jump it to the corner. Because it is the
* same function the command uses, what is previewed is exactly what is committed.
*/
function rectHandlers(
host: InspectorHost,
assetId: string,
frameId: string,
key: keyof FrameRect,
): FieldHandlers {
return previewingHandlers<TextureFrame, FrameRect>(host, {
target: () => currentFrame(host.state.project, assetId, frameId),
read: frameRect,
write: setFrameRect,
toModel: (input, frame) => {
const asset = currentTexture(host.state.project, assetId);
return normalizeFrameRect(
{ ...frameRect(frame), [key]: Number(input) },
asset?.width ?? 0,
asset?.height ?? 0,
);
},
commit: (rect) => new SetFrameRectCommand(assetId, frameId, rect),
});
}
/**
* The frame is looked up inside the selected texture rather than by its id alone: the
* selection names both, and honouring both keeps a selection left over from a deleted texture
* from editing whichever frame later inherits the id.
*/
function currentFrame(project: Project, assetId: string, frameId: string): TextureFrame | undefined {
return currentTexture(project, assetId)?.frames.find((f) => f.id === frameId);
}
function currentTexture(project: Project, assetId: string): TextureAsset | undefined {
const asset = findAsset(project, assetId);
return asset?.type === 'texture' ? asset : undefined;
}
+2
View File
@@ -1,3 +1,4 @@
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { ui } from '../ui';
import { confirmDeleteAsset, createContainer, removeObjects } from './actions';
@@ -38,6 +39,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
const s = state.selection;
if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
return s !== null;
}
+22
View File
@@ -14,6 +14,8 @@ export type Selection =
| { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
| null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */
@@ -235,6 +237,22 @@ export class EditorState {
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'frame') {
const asset = findAsset(this.project, s.assetId);
// Same fallback as a graph node: the texture stays open when its frame vanished.
if (!asset || asset.type !== 'texture') this.select(null);
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
}
/** The texture asset the current selection refers to (the asset itself or one of its frames). */
get selectedTextureId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind !== 'asset' && s.kind !== 'frame') return null;
const asset = findAsset(this.project, s.assetId);
return asset && asset.type === 'texture' ? asset.id : null;
}
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
@@ -242,6 +260,9 @@ export class EditorState {
const s = this.selection;
if (!s) return null;
if (s.kind === 'node') return s.assetId;
// A frame names a texture and can never lead to a behavior. Answered here rather than
// fallen through, so what remains below is the asset/object pair the lookup assumes.
if (s.kind === 'frame') return null;
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
const asset = assetId ? findAsset(this.project, assetId) : undefined;
@@ -276,5 +297,6 @@ export function sameSelection(a: Selection, b: Selection): boolean {
);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
return false;
}
+115
View File
@@ -0,0 +1,115 @@
import type { TextureAsset } from '../../core/model/Asset';
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
/** Width of the label column, matching the inspector's so the two read as one language. */
const LABEL_WIDTH = 76;
const FIELD_WIDTH = 76;
/**
* Cuts a sheet into a grid. Asks for the number of columns and rows rather than a cell size:
* everyone knows how many tiles their sheet has, and deriving the cell size from the picture
* cannot disagree with it.
*
* The dialog states, live, how many frames the current numbers would produce and how big each
* one is. That readout is the whole point – it turns "did I mean 8×4 or 4×8" from something
* you find out by undoing into something you see before committing.
*/
export class SliceDialog extends Popup {
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
private replace = true;
private readonly summary: Label;
private readonly confirm: Button;
constructor(
private readonly asset: TextureAsset,
onSlice: (options: SliceOptions, replace: boolean) => void,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 340;
this.content.add(new Label(`Slice "${asset.name}" into a grid`, { style: 'bodyBold' }));
this.content.add(new Label(`Picture is ${asset.width} × ${asset.height} px`, { style: 'muted' }));
this.content.add(this.pair('Columns', 'columns', 1, 'Rows', 'rows', 1));
this.content.add(this.pair('Offset X', 'offsetX', 0, 'Offset Y', 'offsetY', 0));
this.content.add(this.pair('Spacing X', 'spacingX', 0, 'Spacing Y', 'spacingY', 0));
const replaceRow = new HStack({ gap: Theme.gap, align: 'center' });
replaceRow.minHeight = Theme.rowHeight;
const box = new Checkbox(this.replace);
box.onChange = (value) => {
this.replace = Boolean(value);
};
replaceRow.add(box);
replaceRow.add(new Label('Replace the frames this texture already has', { style: 'muted' }));
this.content.add(replaceRow);
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.confirm = new Button('Slice', { variant: 'primary' });
this.confirm.onClick = this.closeThen(() => onSlice({ ...this.options }, this.replace));
buttons.add(this.confirm);
this.content.add(buttons);
this.refreshSummary();
}
/** Two numeric fields on one row – the grid's parameters come in x/y pairs. */
private pair(
labelA: string,
keyA: keyof SliceOptions,
minA: number,
labelB: string,
keyB: keyof SliceOptions,
minB: number,
): Widget {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.labelFor(labelA));
row.add(this.fieldFor(keyA, minA));
row.add(new Spacer(Theme.padding));
row.add(this.labelFor(labelB));
row.add(this.fieldFor(keyB, minB));
row.add(new Spacer(), 1);
return row;
}
private labelFor(text: string): Label {
const label = new Label(text, { style: 'muted' });
label.minWidth = LABEL_WIDTH;
return label;
}
private fieldFor(key: keyof SliceOptions, min: number): NumberField {
const field = new NumberField(this.options[key], {
min,
step: 1,
onChange: (value) => {
this.options[key] = Math.round(Number(value));
this.refreshSummary();
},
});
field.minWidth = FIELD_WIDTH;
return field;
}
/** Live readout of what the current numbers would do, including the refusal case. */
private refreshSummary(): void {
const rects = sliceFrames(this.asset.width, this.asset.height, this.options);
const first = rects[0];
this.summary.setText(
first
? `${rects.length} frames of ${first.w} × ${first.h} px`
: 'These numbers do not fit the picture – no frames.',
);
this.summary.setStyle(first ? 'muted' : 'body');
this.confirm.setEnabled(rects.length > 0);
this.refreshLayout();
}
}
+498
View File
@@ -0,0 +1,498 @@
import {
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
Sprite,
Text,
} from 'pixi.js';
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import {
dragFrame,
fitView,
type FrameHandle,
frameHandles,
handleAt,
handleCursor,
handlePoint,
imageToLocal,
localToImage,
rectBetween,
type SheetView,
zoomAbout,
} from './sheetView';
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
const HANDLE_SIZE = 8;
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/** Side of one square of the transparency checkerboard, in screen pixels. */
const CHECKER_SIZE = 8;
const CHECKER_DARK = 0x1a1c21;
const CHECKER_LIGHT = 0x24272e;
/** Below this on-screen frame size the name is illegible clutter and is left out. */
const MIN_LABEL_SIZE = 28;
/** Inset of a frame's name plate from the frame's top-left corner, and its own padding. */
const LABEL_INSET = 3;
const LABEL_PAD = 2;
/** A drag of an existing frame: which one, from what, and by which handle. */
interface FrameDrag {
frameId: string;
handle: FrameHandle;
start: FrameRect;
pointer: Vec2;
moved: boolean;
}
/** A new frame being spanned on empty space (image pixels; nothing is in the model yet). */
interface NewFrame {
start: Vec2;
current: Vec2;
active: boolean;
}
/**
* The Texture tab's canvas: one texture's picture with its frames drawn on top, where frames
* are created by dragging on empty space and moved or resized by their handles.
*
* Everything but the picture is drawn in widget-local pixels rather than in a zoomed
* container: outlines, handles and names have to keep a constant size on screen, which a
* scaled container would defeat. The picture itself is a Sprite whose scale is the zoom, so
* the GPU does the sampling – and `scaleMode` follows the texture's own Filter property, so a
* pixel-art sheet is inspected as crisp pixels rather than as the mush a smoothed preview
* would show.
*/
export class TextureCanvas extends Widget {
private readonly bg = new Graphics();
private readonly checker = new Graphics();
private readonly imageLayer = new Container();
private readonly image = new Sprite();
private readonly overlay = new Graphics();
private readonly labels = new Map<string, Text>();
private readonly labelLayer = new Container();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
private assetId = '';
/** Fit the picture once the widget has a real size and a picture to fit (it starts hidden). */
private needsFit = true;
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null;
constructor(private readonly state: EditorState) {
super();
this.imageLayer.addChild(this.image);
this.addChild(
this.bg,
this.checker,
this.imageLayer,
this.overlay,
this.labelLayer,
this.hint,
this.clip,
);
this.imageLayer.mask = this.clip;
this.imageLayer.eventMode = 'none';
this.overlay.eventMode = 'none';
this.labelLayer.eventMode = 'none';
this.hint.eventMode = 'none';
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
state.on('selection', () => this.redraw());
state.on('load', () => {
this.needsFit = true;
this.redraw();
});
// A picture arrives asynchronously; until then there is nothing to fit the view to.
textureLibrary.onChange(() => this.redraw());
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
/** Which texture is shown. Switching resets the view, since the next picture is a new subject. */
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.needsFit = true;
this.redraw();
}
get currentAssetId(): string {
return this.assetId;
}
/** Refits the picture into the widget – the "reset view" the toolbar offers. */
fit(): void {
this.needsFit = true;
this.redraw();
}
/**
* Where an image pixel currently sits on screen. Only the check scripts need this: dragging
* a frame is done in image coordinates, so a test that cannot convert them can drag
* somewhere but cannot aim at a known part of the picture.
*/
imageToScreen(x: number, y: number): Vec2 {
const local = imageToLocal(this.view, x, y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
// --- model ------------------------------------------------------------------------------
private asset(): TextureAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'texture' ? asset : null;
}
private selectedFrameId(): string {
const s = this.state.selection;
return s?.kind === 'frame' && s.assetId === this.assetId ? s.frameId : '';
}
private frame(frameId: string): TextureFrame | undefined {
return this.asset()?.frames.find((f) => f.id === frameId);
}
// --- pointer ----------------------------------------------------------------------------
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
// Nothing is cut from a picture whose size is unknown: `normalizeFrameRect` has no bounds
// to hold such a frame inside, so it would keep a rectangle that lies off the sheet for
// good. An unmeasured texture is a failed upload, and the hint says so.
if (!asset || asset.width <= 0 || asset.height <= 0) return;
const local = this.toLocal(e.global);
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation();
return;
}
if (e.button !== 0) return;
ui().focus.focus(this);
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
// The selected frame is tested first and by its handles: it is the one being worked on,
// and its handles must stay reachable even where a frame drawn later overlaps them.
const selected = this.frame(this.selectedFrameId());
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
if (selected && onSelected) {
this.startDrag(selected, onSelected, local);
e.stopPropagation();
return;
}
// Last first: later frames are drawn on top, so they are what a click on an overlap means.
for (let i = asset.frames.length - 1; i >= 0; i--) {
const frame = asset.frames[i]!;
if (handleAt(frame, point, tolerance) === null) continue;
this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id });
this.startDrag(frame, 'move', local);
e.stopPropagation();
return;
}
this.spanning = { start: point, current: point, active: false };
e.stopPropagation();
}
private startDrag(frame: TextureFrame, handle: FrameHandle, local: Vec2): void {
this.dragging = {
frameId: frame.id,
handle,
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
pointer: { x: local.x, y: local.y },
moved: false,
};
}
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.panning) {
this.view = {
...this.view,
panX: this.panning.panX + (local.x - this.panning.pointer.x),
panY: this.panning.panY + (local.y - this.panning.pointer.y),
};
this.redraw();
return;
}
if (this.dragging) {
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.view.zoom < DRAG_THRESHOLD) return;
this.dragging.moved = true;
this.previewDrag(dx, dy);
return;
}
if (this.spanning) {
this.spanning.current = localToImage(this.view, local.x, local.y);
const size = rectBetween(this.spanning.start, this.spanning.current);
if (!this.spanning.active && Math.max(size.w, size.h) * this.view.zoom < DRAG_THRESHOLD) return;
this.spanning.active = true;
this.redraw();
return;
}
this.updateCursor(local);
}
/** Applies a drag to the model directly, as a preview: no undo entry until the pointer is up. */
private previewDrag(dx: number, dy: number): void {
const drag = this.dragging;
const asset = this.asset();
const frame = drag && this.frame(drag.frameId);
if (!drag || !asset || !frame) return;
Object.assign(
frame,
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
);
this.state.preview();
}
private onPointerUp(): void {
this.panning = null;
const drag = this.dragging;
const span = this.spanning;
this.dragging = null;
this.spanning = null;
if (drag) {
const asset = this.asset();
const frame = this.frame(drag.frameId);
if (drag.moved && asset && frame) {
// The preview moved it already; put it back so the command records the real start.
const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
Object.assign(frame, drag.start);
this.state.execute(new SetFrameRectCommand(asset.id, drag.frameId, next));
}
return;
}
if (!span) return;
if (!span.active) {
// A click on empty space clears the selection, like everywhere else in the editor.
this.state.select({ kind: 'asset', assetId: this.assetId });
this.redraw();
return;
}
const asset = this.asset();
if (!asset) return;
const rect = normalizeFrameRect(rectBetween(span.start, span.current), asset.width, asset.height);
if (rect.w < MIN_FRAME_SIZE || rect.h < MIN_FRAME_SIZE) return;
const command = new AddFrameCommand(asset.id, rect);
if (this.state.execute(command))
this.state.select({ kind: 'frame', assetId: asset.id, frameId: command.frameId });
this.redraw();
}
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
this.needsFit = false;
this.redraw();
e.preventDefault();
}
/** Announces what a press would do before it happens (resize arrows, move, crosshair). */
private updateCursor(local: Vec2): void {
const asset = this.asset();
if (!asset) {
this.cursor = 'default';
return;
}
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
const selected = this.frame(this.selectedFrameId());
const handle = selected ? handleAt(selected, point, tolerance) : null;
if (handle) {
this.cursor = handleCursor(handle);
return;
}
const over = asset.frames.some((f) => handleAt(f, point, tolerance) !== null);
this.cursor = over ? 'move' : 'crosshair';
}
// --- drawing ----------------------------------------------------------------------------
override redraw(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
const asset = this.asset();
const texture = asset && asset.file ? this.pictureOf(asset) : null;
if (asset && texture && this.needsFit && this.w > 0 && this.h > 0) {
this.view = fitView(asset.width, asset.height, this.w, this.h);
this.needsFit = false;
}
this.image.visible = Boolean(texture);
if (asset && texture) {
this.image.texture = texture;
this.image.position.set(this.view.panX, this.view.panY);
// The sprite is drawn at the picture's own pixel size, so the zoom is the whole scale.
this.image.width = asset.width * this.view.zoom;
this.image.height = asset.height * this.view.zoom;
}
this.drawChecker(asset, Boolean(texture));
this.drawFrames(asset);
this.syncHint(asset, Boolean(texture));
}
private pictureOf(asset: TextureAsset) {
return textureLibrary.get({
file: asset.file,
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
rect: null,
sourceWidth: asset.width,
sourceHeight: asset.height,
});
}
/**
* The checkerboard behind the picture. It is clipped to the picture rather than filling the
* widget: its job is to make transparent pixels of *this image* visible, and a board that
* ran past the edges would hide where the image actually ends.
*/
private drawChecker(asset: TextureAsset | null, hasPicture: boolean): void {
this.checker.clear();
if (!asset || !hasPicture || asset.width <= 0) return;
const origin = imageToLocal(this.view, 0, 0);
const w = asset.width * this.view.zoom;
const h = asset.height * this.view.zoom;
this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK);
// Only the squares that land inside the widget are worth emitting; a 4000 px sheet at a
// high zoom would otherwise cost tens of thousands of rectangles per redraw.
const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE));
const x0 = from(origin.x, 0);
const y0 = from(origin.y, 0);
const x1 = Math.min(Math.ceil(w / CHECKER_SIZE), from(origin.x, this.w) + 1);
const y1 = Math.min(Math.ceil(h / CHECKER_SIZE), from(origin.y, this.h) + 1);
for (let cy = y0; cy < y1; cy++) {
for (let cx = x0; cx < x1; cx++) {
if ((cx + cy) % 2 === 0) continue;
const px = origin.x + cx * CHECKER_SIZE;
const py = origin.y + cy * CHECKER_SIZE;
this.checker.rect(
px,
py,
Math.min(CHECKER_SIZE, w - cx * CHECKER_SIZE),
Math.min(CHECKER_SIZE, h - cy * CHECKER_SIZE),
);
}
}
this.checker.fill(CHECKER_LIGHT);
}
private drawFrames(asset: TextureAsset | null): void {
this.overlay.clear();
const alive = new Set<string>();
if (asset) {
const selectedId = this.selectedFrameId();
for (const frame of asset.frames) {
alive.add(frame.id);
this.drawFrame(frame, frame.id === selectedId);
}
const selected = this.frame(selectedId);
if (selected) this.drawHandles(selected);
}
if (this.spanning?.active) {
const rect = rectBetween(this.spanning.start, this.spanning.current);
const a = imageToLocal(this.view, rect.x, rect.y);
this.overlay
.rect(a.x, a.y, rect.w * this.view.zoom, rect.h * this.view.zoom)
.fill({ color: Theme.colors.marquee, alpha: 0.15 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
for (const [id, label] of this.labels) {
if (alive.has(id)) continue;
label.destroy();
this.labels.delete(id);
}
}
private drawFrame(frame: TextureFrame, selected: boolean): void {
const a = imageToLocal(this.view, frame.x, frame.y);
const w = frame.w * this.view.zoom;
const h = frame.h * this.view.zoom;
const color = selected ? Theme.colors.accent : Theme.colors.collider;
this.overlay.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color });
this.syncLabel(frame, a, w, h, selected);
}
private drawHandles(frame: TextureFrame): void {
const half = HANDLE_SIZE / 2;
for (const handle of frameHandles()) {
const p = handlePoint(frame, handle);
const local = imageToLocal(this.view, p.x, p.y);
this.overlay
.rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE)
.fill(Theme.colors.accentText)
.stroke({ width: 1, color: Theme.colors.accent });
}
}
private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void {
let label = this.labels.get(frame.id);
// A name that does not fit inside its frame would be read as belonging to the neighbour.
const show = Math.min(w, h) >= MIN_LABEL_SIZE && at.x < this.w && at.y < this.h && at.x + w > 0;
if (!show) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ style: textStyle('body') });
this.labels.set(frame.id, label);
this.labelLayer.addChild(label);
}
label.visible = true;
if (label.text !== frame.name) label.text = frame.name;
label.style.fill = Theme.colors.text;
const x = at.x + LABEL_INSET;
const y = at.y + LABEL_INSET;
label.position.set(x + LABEL_PAD, y + LABEL_PAD);
/*
* A dark plate behind the name, not just a colour choice for the text.
*
* The name is drawn *on the user's picture*, which may be any colour – the grey used at
* first vanished on the light quadrants of a test sheet, and no fixed colour can be safe on
* an arbitrary image. The plate makes the background known, so light text on it always
* reads. Same reasoning as the scene grid, which is drawn white at a low alpha rather than
* in a theme colour so that it survives whatever the scene puts behind it.
*/
this.overlay
.roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2)
.fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 });
}
private syncHint(asset: TextureAsset | null, hasPicture: boolean): void {
const text = !asset
? 'No texture selected – pick one in the Assets panel or import an image.'
: !asset.file
? 'This texture has no picture yet.'
: !hasPicture
? 'Loading…'
: asset.frames.length === 0
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
: '';
this.hint.text = text;
this.hint.visible = text !== '';
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { Graphics } from 'pixi.js';
import { SliceFramesCommand } from '../../core/commands/frameCommands';
import type { TextureAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
import type { EditorState } from '../state';
import { SliceDialog } from './SliceDialog';
import { TextureCanvas } from './TextureCanvas';
/** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */
const INFO_WIDTH = 190;
/**
* The Texture tab: a picker for which texture is being worked on, the tools that act on the
* whole sheet, and the canvas the frames are drawn on.
*
* It follows the selection the way the graph editor does – selecting a texture asset (or one
* of its frames) shows it here – but it does not *force* the tab, and it keeps showing the
* last texture when the selection moves elsewhere. Otherwise clicking a sprite to check
* whether it wears the right frame would empty the tab you are working in.
*/
export class TextureEditor extends VStack {
private readonly canvas: TextureCanvas;
private readonly picker: Dropdown;
private readonly info: Label;
private readonly sliceButton: Button;
private readonly headerBg = new Graphics();
constructor(private readonly state: EditorState) {
super();
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
header.addChildAt(this.headerBg, 0);
header.add(new Label('Texture', { style: 'muted' }));
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
header.add(this.picker);
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
header.add(this.sliceButton);
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
header.add(new Spacer(), 1);
this.info = new Label('', { style: 'small', align: 'right' });
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
// the text it had at the time – so without a floor the readout is clipped from then on.
this.info.minWidth = INFO_WIDTH;
header.add(this.info);
this.canvas = new TextureCanvas(state);
this.add(header);
this.add(this.canvas, 1);
state.on('selection', () => this.followSelection());
state.on('project', () => this.refresh());
state.on('load', () => {
this.canvas.show('');
this.followSelection();
});
this.followSelection();
}
/** The texture currently shown – the check scripts read this. */
get currentAssetId(): string {
return this.canvas.currentAssetId;
}
/** Shows a texture explicitly (opening the tab for a freshly imported image). */
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
}
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
imageToScreen(x: number, y: number): { x: number; y: number } {
return this.canvas.imageToScreen(x, y);
}
private followSelection(): void {
const id = this.state.selectedTextureId;
// Null merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? '');
this.refresh();
}
private asset(): TextureAsset | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && asset.type === 'texture' ? asset : null;
}
private textures(): TextureAsset[] {
return this.state.project.assets
.filter((a): a is TextureAsset => a.type === 'texture')
.sort((a, b) => a.name.localeCompare(b.name));
}
private openPicker(): void {
const items: MenuItem[] = this.textures().map((asset) => ({
label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name,
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
}));
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
}
private openSlice(): void {
const asset = this.asset();
if (!asset) return;
new SliceDialog(asset, (options, replace) => {
this.state.execute(new SliceFramesCommand(asset.id, options, replace));
}).openCentered();
}
private refresh(): void {
const asset = this.asset();
this.picker.setText(asset ? asset.name : 'No texture');
const count = asset?.frames.length ?? 0;
this.info.setText(
asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '',
);
// Slicing needs a known picture size; without one it could only fail with a notice.
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
}
override redraw(): void {
super.redraw();
this.headerBg.clear();
const h = Theme.controlHeight + Theme.padding * 2;
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* How the Texture tab maps image pixels onto the widget, and what a drag on a frame means.
* Pure geometry – no Pixi, no editor state – so the fiddly part of the editor (which handle
* did I grab, what does dragging it do) is unit-testable instead of only clickable.
*/
import type { FrameRect } from '../../core/model/texture';
import type { Vec2 } from '../../core/model/Properties';
/** Image pixel `(0, 0)` sits at (`panX`, `panY`) in widget-local pixels, at `zoom` per pixel. */
export interface SheetView {
panX: number;
panY: number;
zoom: number;
}
export const MIN_SHEET_ZOOM = 0.05;
export const MAX_SHEET_ZOOM = 64;
/** Fraction of the widget the picture is fitted into, leaving a margin to grab and pan by. */
const FIT_MARGIN = 0.9;
export function imageToLocal(view: SheetView, x: number, y: number): Vec2 {
return { x: view.panX + x * view.zoom, y: view.panY + y * view.zoom };
}
export function localToImage(view: SheetView, x: number, y: number): Vec2 {
return { x: (x - view.panX) / view.zoom, y: (y - view.panY) / view.zoom };
}
/** The view that centres the whole picture in a `w`×`h` widget. */
export function fitView(imageWidth: number, imageHeight: number, w: number, h: number): SheetView {
if (imageWidth <= 0 || imageHeight <= 0 || w <= 0 || h <= 0) return { panX: 0, panY: 0, zoom: 1 };
const zoom = clampZoom(Math.min(w / imageWidth, h / imageHeight) * FIT_MARGIN || 1);
return {
panX: (w - imageWidth * zoom) / 2,
panY: (h - imageHeight * zoom) / 2,
zoom,
};
}
export function clampZoom(zoom: number): number {
return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom));
}
/** Zooms about a fixed widget-local point, so the pixel under the cursor stays under it. */
export function zoomAbout(view: SheetView, factor: number, localX: number, localY: number): SheetView {
const zoom = clampZoom(view.zoom * factor);
const before = localToImage(view, localX, localY);
const after = { panX: localX - before.x * zoom, panY: localY - before.y * zoom, zoom };
return after;
}
/**
* Which part of a frame a point grabs: a corner or edge handle (`hx`/`hy` in −1/0/1, naming
* the edge each axis is pinned to), `'move'` anywhere else inside it, or null outside.
*/
export type FrameHandle = { hx: -1 | 0 | 1; hy: -1 | 0 | 1 } | 'move';
/** The nine anchor points of a rectangle; (0, 0) is its centre and is not a handle. */
const HANDLES: { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] = [
{ hx: -1, hy: -1 },
{ hx: 1, hy: -1 },
{ hx: -1, hy: 1 },
{ hx: 1, hy: 1 },
{ hx: 0, hy: -1 },
{ hx: 0, hy: 1 },
{ hx: -1, hy: 0 },
{ hx: 1, hy: 0 },
];
/** Where a handle sits, in image pixels. */
export function handlePoint(rect: FrameRect, handle: { hx: number; hy: number }): Vec2 {
return {
x: rect.x + ((handle.hx + 1) / 2) * rect.w,
y: rect.y + ((handle.hy + 1) / 2) * rect.h,
};
}
export function frameHandles(): readonly { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] {
return HANDLES;
}
/**
* What a press at `point` (image pixels) does to `rect`. `tolerance` is the grab radius in
* image pixels – the caller converts a constant screen distance, so handles stay equally easy
* to hit at every zoom.
*
* Corners are tested before edges so that at a small frame, where all handles overlap, the
* corner wins: a corner resizes both axes and is the one worth reaching for.
*/
export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): FrameHandle | null {
for (const handle of HANDLES) {
const p = handlePoint(rect, handle);
if (Math.abs(point.x - p.x) <= tolerance && Math.abs(point.y - p.y) <= tolerance) return handle;
}
const inside =
point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h;
return inside ? 'move' : null;
}
/**
* The rectangle a drag produces: `handle` decides which edges follow the pointer. The result
* may be inverted or stick out of the image – squaring it up is `normalizeFrameRect`'s job,
* so that a drag and a typed-in value are corrected by exactly one piece of code.
*/
export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy: number): FrameRect {
if (handle === 'move') return { ...start, x: start.x + dx, y: start.y + dy };
const rect = { ...start };
if (handle.hx === -1) {
rect.x += dx;
rect.w -= dx;
} else if (handle.hx === 1) {
rect.w += dx;
}
if (handle.hy === -1) {
rect.y += dy;
rect.h -= dy;
} else if (handle.hy === 1) {
rect.h += dy;
}
return rect;
}
/** The rectangle spanned by two points, in either order. */
export function rectBetween(a: Vec2, b: Vec2): FrameRect {
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
}
/** The mouse cursor a handle asks for, so a resize edge announces itself before it is grabbed. */
export function handleCursor(handle: FrameHandle): string {
if (handle === 'move') return 'move';
if (handle.hx === 0) return 'ns-resize';
if (handle.hy === 0) return 'ew-resize';
return handle.hx === handle.hy ? 'nwse-resize' : 'nesw-resize';
}
+25 -4
View File
@@ -3,11 +3,13 @@
* separate Pixi Text), and the editor symbols for cameras, behaviors, containers and
* colliders. Everything here draws in world units at the item's origin.
*/
import { type Graphics, Rectangle } from 'pixi.js';
import { type Graphics, Matrix, Rectangle, type Texture } from 'pixi.js';
import type { TextureRef } from '../core/model/texture';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import { frameFillMatrix } from './textureFill';
import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
@@ -32,10 +34,9 @@ export function drawItem(
// Null while the picture is still loading, so the box shows its plain colour until then –
// which is exactly what a sprite looked like before textures existed. No flicker, no
// placeholder. With a picture the colour tints it (verified: white x 0x66aaff reads back
// as rgb(98,163,245)), and Pixi fits an unmatrixed texture fill to the shape's bounds,
// so nothing here has to compute a texture matrix.
// as rgb(98,163,245)).
const texture = item.texture ? textures.get(item.texture) : null;
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(texture ? { texture, color } : color);
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color, item.texture));
return;
}
case 'camera':
@@ -101,6 +102,26 @@ export function drawItem(
}
}
/**
* The fill a sprite's box takes: the plain colour while there is no picture, the picture
* tinted by that colour once there is one.
*
* Only a frame gets a matrix. An unmatrixed fill spreads the whole image over the shape, which
* is precisely what a whole-image sprite wants, and skipping the matrix there saves Pixi the
* inversion on every redraw of every such sprite. See `textureFill.ts` for why a frame cannot
* be drawn as a Pixi sub-texture instead.
*/
function spriteFill(
texture: Texture | null,
color: number,
ref: TextureRef | null,
): number | { texture: Texture; color: number; matrix?: Matrix } {
if (!texture || !ref) return color;
const m = ref.rect && frameFillMatrix(ref.rect, ref.sourceWidth, ref.sourceHeight);
if (!m) return { texture, color };
return { texture, color, matrix: new Matrix(m.a, m.b, m.c, m.d, m.tx, m.ty) };
}
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
function colliderOutlineHitArea(
shape: ColliderShape,
+48
View File
@@ -0,0 +1,48 @@
import type { FrameRect } from '../core/model/texture';
/** The six numbers of a 2D affine matrix, in Pixi's `Matrix(a, b, c, d, tx, ty)` order. */
export interface MatrixValues {
a: number;
b: number;
c: number;
d: number;
tx: number;
ty: number;
}
/**
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight`
* image covers the filled shape – how a sprite wears one frame of a sheet.
*
* Measured, not assumed. Two things about Pixi v8 make this the answer:
*
* - A `Texture` built with a `frame` reports the frame's size, but a Graphics **fill ignores
* it** and draws the whole source. Verified by reading back pixels: a fill with a 32×32
* frame of a four-quadrant sheet showed all four quadrants. Sub-textures are therefore not
* a way to draw a frame.
* - `generateTextureMatrix` computes `uv = inverse(style.matrix) ∘ normalise(shapeBounds)`,
* where the normaliser maps the shape's bounding box onto the unit square. So an absent
* matrix spreads the whole image over the shape (which is why a whole-image sprite needs
* none), and to spread a sub-rectangle instead, `inverse(style.matrix)` has to be the map
* from the unit square onto that rectangle in UV space – making the matrix its inverse.
*
* Because the normaliser works off the shape's own bounds, this is independent of where the
* shape sits and how big it is; verified with an off-origin, non-square shape.
*/
export function frameFillMatrix(
rect: FrameRect,
sourceWidth: number,
sourceHeight: number,
): MatrixValues | null {
// Without a known image size the rectangle has no reference frame; drawing the whole
// picture is the honest answer, and it is what the sprite showed a moment ago anyway.
if (sourceWidth <= 0 || sourceHeight <= 0 || rect.w <= 0 || rect.h <= 0) return null;
return {
a: sourceWidth / rect.w,
b: 0,
c: 0,
d: sourceHeight / rect.h,
tx: -rect.x / rect.w,
ty: -rect.y / rect.h,
};
}