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