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:
adminandClaude Opus 5.5 committed 2026-09-24 19:34:37 +02:00
1 parent 50854389bb
commit 89048413f1
11 files changed
+334 -11

No files matched your search

+27
View File
@@ -3,6 +3,7 @@ import {
RenameFrameCommand,
SetFrameRectCommand,
SetFrameSliceCommand,
SetFrameSortLineCommand,
} from '../../core/commands/frameCommands';
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
import { type PropertyValue, textOf } from '../../core/model/Properties';
@@ -15,9 +16,12 @@ import {
type FrameRect,
frameSlice,
type FrameSlice,
frameSortLine,
normalizeFrameRect,
normalizeSortLine,
setFrameRect,
setFrameSlice,
setFrameSortLine,
} from '../../core/model/texture';
import { Button } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
@@ -70,6 +74,10 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId:
fillRow(host, assetId, frameId);
addNote(host, 'All four at zero: the picture is drawn over the whole box, as it always was.');
addTitle(host, 'SORTING (IMAGE PIXELS)');
sortLineRow(host, assetId, frameId);
addNote(host, 'Where a sprite of this frame stands on a layer sorted by Y, up from the bottom edge.');
addGap(host);
host.body.add(
new Button('Delete frame', {
@@ -89,9 +97,28 @@ export function refreshFrameSection(host: InspectorHost, assetId: string, frameI
for (const component of BORDER_COMPONENTS)
host.fields.get(`border-${component.key}`)?.setValue(border[component.key]);
host.fields.get('fill')?.setValue(frameFill(frame));
host.fields.get('sortLine')?.setValue(frameSortLine(frame));
return true;
}
function sortLineRow(host: InspectorHost, assetId: string, frameId: string): void {
const frame = currentFrame(host.state.project, assetId, frameId);
const row = createFieldRow(
host,
{ key: 'sortLine', label: 'Sort line', kind: 'number', default: 0, min: 0, step: 1, decimals: 0 },
frame ? frameSortLine(frame) : 0,
previewingHandlers<TextureFrame, number>(host, {
target: () => currentFrame(host.state.project, assetId, frameId),
read: frameSortLine,
write: setFrameSortLine,
toModel: (input, frame) => normalizeSortLine(Number(input), frame.h),
commit: (line) => new SetFrameSortLineCommand(assetId, frameId, line),
}),
);
host.fields.set('sortLine', row);
host.body.add(row.row);
}
/**
* One border of the nine-slice, in pixels of the frame. The row keeps the other three and the fill
* as they are at that moment, so two rows edited one after the other do not undo each other.
+78 -3
View File
@@ -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
+23
View File
@@ -167,6 +167,29 @@ export function dragBorder(border: FrameBorders, edge: BorderEdge, dx: number, d
return moved;
}
/** The Y a frame's sort line (#228) runs at, in image pixels. */
export function sortLineY(rect: FrameRect, line: number): number {
return rect.y + rect.h - line;
}
/**
* What a press grabs of the sort line: its knob left of the frame, reachable wherever the line sits,
* or the line itself inside the frame. `reach` is how far left of the frame the knob goes, in image
* pixels. The press tests the knob before the frame's handles and the line after its border lines,
* which at 0 all lie on the bottom edge with it.
*/
export function sortLineAt(
rect: FrameRect,
line: number,
point: Vec2,
tolerance: number,
reach: number,
): 'knob' | 'line' | null {
if (Math.abs(point.y - sortLineY(rect, line)) > tolerance) return null;
if (point.x >= rect.x - reach && point.x < rect.x - tolerance) return 'knob';
return point.x >= rect.x && point.x <= rect.x + rect.w ? 'line' : null;
}
export function borderCursor(edge: BorderEdge): string {
return edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize';
}