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

+26
View File
@@ -18,6 +18,7 @@ import {
setFrameCollision,
setFrameRect,
setFrameSlice,
setFrameSortLine,
sliceFrames,
type SliceOptions,
uniqueFrameName,
@@ -121,6 +122,31 @@ export class SetFrameSliceCommand implements Command {
}
}
/** 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
+5
View File
@@ -109,6 +109,11 @@ export interface TextureFrame {
* – its bordered tile and its notches. Absent is none; one of fewer than three corners collides as none.
*/
collision?: Vec2[][];
/**
* Where a sprite wearing this frame stands when a layer sorts by Y (#228): whole image pixels up
* from the frame's bottom edge. Absent is 0, the bottom edge itself.
*/
sortLine?: number;
}
/**
+22
View File
@@ -33,6 +33,8 @@ export interface TextureRef {
sourceHeight: number;
/** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */
slice: NineSlice | null;
/** The frame's sort line (#228) in image pixels up from its bottom; absent is 0. */
sortLine?: number;
}
/**
@@ -119,6 +121,7 @@ function generatedRef(asset: Asset, project: Project, rect: FrameRect | null = n
function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null {
if (!isTextureFile(asset.file)) return null;
const sortLine = frame ? frameSortLine(frame) : 0;
return {
file: asset.file,
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
@@ -126,9 +129,28 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
sourceWidth: asset.width,
sourceHeight: asset.height,
slice: frame ? nineSliceOf(frame, sliceScaleOf(asset)) : null,
...(sortLine > 0 ? { sortLine } : {}),
};
}
/** Whole pixels from the frame's bottom edge up to its top edge at most. */
export function normalizeSortLine(value: number, h: number): number {
const line = Math.round(Number.isFinite(value) ? value : 0);
return Math.max(0, Math.min(Math.floor(h), line));
}
/** Read against the frame as it is now, so a frame resized smaller keeps its line inside it. */
export function frameSortLine(frame: TextureFrame): number {
return normalizeSortLine(frame.sortLine ?? 0, frame.h);
}
/** 0 removes the key, so a frame nobody gave a line saves as it did before. */
export function setFrameSortLine(frame: TextureFrame, value: number | undefined): void {
const line = normalizeSortLine(value ?? 0, frame.h);
if (line > 0) frame.sortLine = line;
else delete frame.sortLine;
}
const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
export const DEFAULT_FILL: FrameFill = 'repeat';
+2
View File
@@ -64,6 +64,7 @@ import {
normalizeFrameRect,
setFrameCollision,
setFrameSlice,
setFrameSortLine,
} from '../model/texture';
import { pointsOf } from '../model/polygon';
import { canvasSide, PALETTE_MAX } from '../model/pixelart';
@@ -581,6 +582,7 @@ function normalizeTexture(
// Corners that are no numbers fall away; what is left is kept only as an outline (#141).
const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3);
if (outlines.length > 0) setFrameCollision(frame, outlines);
setFrameSortLine(frame, toNumber(f.sortLine));
frames.push(frame);
}
return { file, width, height, frames };