Files
feuerwerk/src/core/commands/frameCommands.ts
T
adminandClaude Opus 5.5 89048413f1 Let a texture frame set where a sprite stands for Y sorting (#228)
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>
2026-09-24 19:34:37 +02:00

300 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}