A sprite sorted by Y stood on the bottom of its box. With a shadow or empty rows under the feet that is too low, so a figure slipped in front of a tree it should stand behind. The line belongs to the frame, not the texture, because one texture can hold several sprite sheets. - New field `sortLine` on a frame: whole image pixels up from the frame's bottom edge, 0 to the frame height. 0 is stored as nothing, so old projects stay the same. Shrinking a frame keeps the line inside it. - `feetOf` in drawOrder.ts lifts a sprite by the same share of its box. Scene view, game view, play and picking all use it. In an animation the line of the frame shown right now counts. - The texture tab draws the selected frame's line dashed in yellow with a knob left of the frame. At 0 the line shares the bottom edge with the nine-slice border and a resize handle, so the knob is tested first. A line inside the frame can be dragged directly, tested after the nine-slice lines. - The inspector has a new "Sorting" section with a "Sort line" field. Dragging or typing is one undo step each. Limit: frames of one walk cycle need the same value, set by hand. Otherwise the figure changes its place in the order while walking. Tests: - ./check.sh: 160 files, 2356 tests, CHECK EXIT 0 (before 2350). - 6 new unit tests in tests/viewport/sort-by-y.test.ts. - e2e 37 and 39-43: ALL PASSED, 162 checks. New check 37b-ii drags the knob up 6 px and undoes it, plus the inspector row. - Counter checks: if feetOf ignores the line, exactly the 3 order tests fail. If the knob cannot be grabbed, 37b-ii fails. - The e2e sum in the README is 1380, counted, not yet seen in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
/**
|
||
* Every rectangle passes `normalizeFrameRect` here rather than at the call site: frames come from
|
||
* dragging in a zoomed view, so callers produce fractional and inverted rectangles routinely.
|
||
*/
|
||
import { newId } from '../ids';
|
||
import type { TextureAsset, TextureFrame } from '../model/Asset';
|
||
import type { Project } from '../model/Project';
|
||
import type { Vec2 } from '../model/Properties';
|
||
import {
|
||
frameRect,
|
||
type FrameRect,
|
||
type FrameSlice,
|
||
nextFrameNames,
|
||
normalizeFrameRect,
|
||
rawFrameSlice,
|
||
frameCollision,
|
||
restoreFrameSlice,
|
||
setFrameCollision,
|
||
setFrameRect,
|
||
setFrameSlice,
|
||
setFrameSortLine,
|
||
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';
|
||
|
||
const DEFAULT_NAME = 'Frame';
|
||
|
||
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;
|
||
}
|
||
|
||
/** `frameId` is known before execution, so the caller can select the new frame right after executing. */
|
||
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 per execution: a redo has to fit the names that exist when it runs.
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One command per drag, not per pointer move. The rectangle to revert to is read from the model at
|
||
* execution time, so a caller that previews a drag in place must restore the start rect before executing.
|
||
*/
|
||
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 rather than normalized again: undo owes the state that was there.
|
||
setFrameRect(requireFrame(asset, this.frameId), this.previous);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The four borders and the fill of one frame (#41), in one command: dragging a border line and
|
||
* picking a fill are the same edit to the reader of an undo list, and splitting them would make
|
||
* "undo" take back half of what the eye saw change.
|
||
*/
|
||
export class SetFrameSliceCommand implements Command {
|
||
readonly label = 'Nine-slice';
|
||
private previous: Pick<TextureFrame, 'border' | 'fill'> = {};
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly frameId: string,
|
||
private readonly slice: FrameSlice,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
|
||
this.previous = rawFrameSlice(frame);
|
||
setFrameSlice(frame, this.slice);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
restoreFrameSlice(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous);
|
||
}
|
||
}
|
||
|
||
/** Where a sprite wearing the frame stands when sorted by Y (#228). */
|
||
export class SetFrameSortLineCommand implements Command {
|
||
readonly label = 'Sort line';
|
||
private previous: number | undefined;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly frameId: string,
|
||
private readonly line: number,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
|
||
if (!Number.isFinite(this.line)) throw new CommandError('The sort line is not a number');
|
||
this.previous = frame.sortLine;
|
||
setFrameSortLine(frame, this.line);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
|
||
if (this.previous === undefined) delete frame.sortLine;
|
||
else frame.sortLine = this.previous;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The collision outlines of one frame (#141), all of them: adding, moving and removing a corner,
|
||
* starting or removing an outline are each one edit, and the outlines to revert to are read at
|
||
* execution time – a caller that previews a drag in place restores the start before executing, as
|
||
* with `SetFrameRectCommand`.
|
||
*/
|
||
export class SetFrameCollisionCommand implements Command {
|
||
readonly label = 'Collision outline';
|
||
private previous: Vec2[][] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly frameId: string,
|
||
private readonly outlines: readonly (readonly Vec2[])[],
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
|
||
if (this.outlines.some((outline) => outline.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))))
|
||
throw new CommandError('A corner of the outline is not a number');
|
||
this.previous = frameCollision(frame);
|
||
setFrameCollision(frame, this.outlines);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
setFrameCollision(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Replaces the outlines of several frames at once – what "Generate collision…" writes (#161), one
|
||
* undo step whether it covered one frame or the whole sheet.
|
||
*/
|
||
export class SetFramesCollisionCommand implements Command {
|
||
readonly label = 'Generate collision';
|
||
private previous = new Map<string, Vec2[][]>();
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly outlines: ReadonlyMap<string, readonly (readonly Vec2[])[]>,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireTexture(project, this.assetId);
|
||
if (this.outlines.size === 0) throw new CommandError('No frame to generate a collision outline for');
|
||
const frames = [...this.outlines.keys()].map((id) => requireFrame(asset, id));
|
||
this.previous = new Map(frames.map((frame) => [frame.id, frameCollision(frame)]));
|
||
for (const frame of frames) setFrameCollision(frame, this.outlines.get(frame.id)!);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const asset = requireTexture(project, this.assetId);
|
||
for (const [id, outlines] of this.previous) setFrameCollision(requireFrame(asset, id), outlines);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Undo restores the former index, because frame order is sheet order. Sprites are left pointing at
|
||
* the frame: a dangling reference reads as "no picture" and comes back by itself on undo.
|
||
*/
|
||
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]);
|
||
}
|
||
}
|
||
|
||
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; a sprite may point at one, and fresh ids
|
||
// 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;
|
||
}
|
||
|
||
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]!,
|
||
...normalizeFrameRect(rect, asset.width, asset.height),
|
||
}));
|
||
asset.frames.push(...created);
|
||
return created;
|
||
}
|
||
}
|