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>
This commit is contained in:
1 parent
50854389bb
commit
89048413f1
11 files changed
+334
-11
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
SetFrameCollisionCommand,
|
||||
SetFrameRectCommand,
|
||||
SetFrameSliceCommand,
|
||||
SetFrameSortLineCommand,
|
||||
} from '../../core/commands/frameCommands';
|
||||
import { polygonProblem } from '../../core/model/polygon';
|
||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
@@ -14,12 +15,15 @@ import {
|
||||
frameCollision,
|
||||
frameFill,
|
||||
type FrameRect,
|
||||
frameSortLine,
|
||||
MIN_FRAME_SIZE,
|
||||
normalizeFrameRect,
|
||||
normalizeSortLine,
|
||||
rawFrameSlice,
|
||||
restoreFrameSlice,
|
||||
setFrameCollision,
|
||||
setFrameSlice,
|
||||
setFrameSortLine,
|
||||
} from '../../core/model/texture';
|
||||
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
|
||||
import {
|
||||
@@ -55,6 +59,8 @@ import {
|
||||
localToImage,
|
||||
rectBetween,
|
||||
type SheetView,
|
||||
sortLineAt,
|
||||
sortLineY,
|
||||
} from './sheetView';
|
||||
|
||||
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
|
||||
@@ -66,12 +72,18 @@ 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;
|
||||
/** The sort line's knob, left of the frame: its gap to the frame and its size, in screen pixels. */
|
||||
const SORT_KNOB_GAP = 4;
|
||||
const SORT_KNOB_SIZE = 8;
|
||||
|
||||
/** Either a handle of the frame's rectangle or one of its nine-slice border lines – never both. */
|
||||
/** A handle of the frame's rectangle, one of its nine-slice border lines, or its sort line. */
|
||||
interface FrameDrag {
|
||||
frameId: string;
|
||||
handle: FrameHandle | null;
|
||||
edge: BorderEdge | null;
|
||||
sortLine: boolean;
|
||||
/** The line as it was, raw, so the command records the state that was really there. */
|
||||
startSortLine: number | undefined;
|
||||
start: FrameRect;
|
||||
startBorder: FrameBorders;
|
||||
/** The slice keys as they were, so the command records the state that was really there. */
|
||||
@@ -185,12 +197,25 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
sheetOutlines: this.sheetOutlines,
|
||||
filled: { sheet: this.sheetFilled, overlay: this.overlayFilled },
|
||||
origin: { x: origin.x, y: origin.y },
|
||||
sortKnob: this.sortKnob(origin),
|
||||
labels: [...this.labels.values()]
|
||||
.filter((label) => label.visible)
|
||||
.map((label) => ({ text: label.text, ...label.getGlobalPosition() })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The middle of the selected frame's sort-line knob on screen, or null without one to grab. */
|
||||
private sortKnob(origin: Vec2): Vec2 | null {
|
||||
const frame = this.frame(this.selectedFrameId());
|
||||
if (!frame || this.collision) return null;
|
||||
const at = imageToLocal(
|
||||
{ panX: 0, panY: 0, zoom: this.view.zoom },
|
||||
frame.x,
|
||||
sortLineY(frame, frameSortLine(frame)),
|
||||
);
|
||||
return { x: origin.x + at.x - SORT_KNOB_GAP - SORT_KNOB_SIZE / 2, y: origin.y + at.y };
|
||||
}
|
||||
|
||||
private asset(): TextureAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset && asset.type === 'texture' ? asset : null;
|
||||
@@ -256,6 +281,12 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
||||
const onBorder = selected ? borderAt(selected, frameBorders(selected), point, tolerance) : null;
|
||||
const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null;
|
||||
if (selected && onSortLine === 'knob') {
|
||||
this.startDrag(selected, null, null, local, true);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (selected && onSelected && onSelected !== 'move') {
|
||||
this.startDrag(selected, onSelected, null, local);
|
||||
e.stopPropagation();
|
||||
@@ -266,6 +297,11 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (selected && onSortLine) {
|
||||
this.startDrag(selected, null, null, local, true);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (selected && onSelected) {
|
||||
this.startDrag(selected, onSelected, null, local);
|
||||
e.stopPropagation();
|
||||
@@ -386,11 +422,14 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
handle: FrameHandle | null,
|
||||
edge: BorderEdge | null,
|
||||
local: Vec2,
|
||||
sortLine = false,
|
||||
): void {
|
||||
this.dragging = {
|
||||
frameId: frame.id,
|
||||
handle,
|
||||
edge,
|
||||
sortLine,
|
||||
startSortLine: frame.sortLine,
|
||||
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
|
||||
startBorder: frameBorders(frame),
|
||||
startSlice: rawFrameSlice(frame),
|
||||
@@ -448,7 +487,8 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
const asset = this.asset();
|
||||
const frame = drag && this.frame(drag.frameId);
|
||||
if (!drag || !asset || !frame) return;
|
||||
if (drag.edge)
|
||||
if (drag.sortLine) setFrameSortLine(frame, normalizeSortLine(drag.startSortLine ?? 0, drag.start.h) - dy);
|
||||
else if (drag.edge)
|
||||
setFrameSlice(frame, {
|
||||
border: dragBorder(drag.startBorder, drag.edge, dx, dy),
|
||||
fill: frameFill(frame),
|
||||
@@ -483,7 +523,12 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
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.
|
||||
if (drag.edge) {
|
||||
if (drag.sortLine) {
|
||||
const next = frameSortLine(frame);
|
||||
if (drag.startSortLine === undefined) delete frame.sortLine;
|
||||
else frame.sortLine = drag.startSortLine;
|
||||
this.state.execute(new SetFrameSortLineCommand(asset.id, drag.frameId, next));
|
||||
} else if (drag.edge) {
|
||||
const next = { border: frameBorders(frame), fill: frameFill(frame) };
|
||||
restoreFrameSlice(frame, drag.startSlice);
|
||||
this.state.execute(new SetFrameSliceCommand(asset.id, drag.frameId, next));
|
||||
@@ -526,6 +571,11 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const handle = selected ? handleAt(selected, point, tolerance) : null;
|
||||
const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null;
|
||||
if (onSortLine === 'knob') {
|
||||
this.cursor = 'ns-resize';
|
||||
return;
|
||||
}
|
||||
if (handle && handle !== 'move') {
|
||||
this.cursor = handleCursor(handle);
|
||||
return;
|
||||
@@ -536,6 +586,10 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
this.cursor = borderCursor(edge);
|
||||
return;
|
||||
}
|
||||
if (onSortLine) {
|
||||
this.cursor = 'ns-resize';
|
||||
return;
|
||||
}
|
||||
if (handle) {
|
||||
this.cursor = handleCursor(handle);
|
||||
return;
|
||||
@@ -622,6 +676,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
this.overlayFilled = this.drawOutlines(this.overlay, view, selected, this.collision, true);
|
||||
if (!this.collision) {
|
||||
this.drawBorders(view, selected);
|
||||
this.drawSortLine(view, selected);
|
||||
this.drawHandles(view, selected);
|
||||
}
|
||||
}
|
||||
@@ -659,6 +714,26 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
|
||||
/** The sort line (#228) across the selected frame, with its knob left of it: a line at 0 is on the edge. */
|
||||
private drawSortLine(view: SheetView, frame: TextureFrame): void {
|
||||
const y = sortLineY(frame, frameSortLine(frame));
|
||||
const a = imageToLocal(view, frame.x, y);
|
||||
const b = imageToLocal(view, frame.x + frame.w, y);
|
||||
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 6, 3);
|
||||
this.overlay.stroke({ width: 1, color: Theme.colors.hot });
|
||||
const tip = a.x - SORT_KNOB_GAP;
|
||||
const half = SORT_KNOB_SIZE / 2;
|
||||
this.overlay
|
||||
.poly([tip, a.y, tip - SORT_KNOB_SIZE, a.y - half, tip - SORT_KNOB_SIZE, a.y + half], true)
|
||||
.fill({ color: Theme.colors.hot })
|
||||
.stroke({ width: 1, color: Theme.colors.symbolOutline });
|
||||
}
|
||||
|
||||
private sortLineAt(frame: TextureFrame, point: Vec2, tolerance: number): 'knob' | 'line' | null {
|
||||
const reach = (SORT_KNOB_GAP + SORT_KNOB_SIZE + 2) / this.view.zoom;
|
||||
return sortLineAt(frame, frameSortLine(frame), point, tolerance, reach);
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame's collision outlines (#141), always shown so one sees which frames block with them; on
|
||||
* the edited frame the active outline is drawn heavier and every corner of every outline gets a
|
||||
|
||||
Reference in new issue
Block a user