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

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1378 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1380 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+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 };
+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';
}
+13 -5
View File
@@ -49,9 +49,9 @@ interface Candidate extends DrawSlot {
* piece. Symbols are left out, they are drawn above everything.
*
* Where a layer sorted by Y sits on a `z`, everything on that `z` is sorted by where it stands
* (#228): an object by the bottom of its box, a row of the layer by the bottom of the row. Ties
* keep the order above. A layer drawn as one piece and the map's own node stay behind them, in
* that order, since neither has a single place to stand.
* (#228): an object by the bottom of its box or by its frame's sort line, a row of the layer by the
* bottom of the row. Ties keep the order above. A layer drawn as one piece and the map's own node
* stay behind them, in that order, since neither has a single place to stand.
*/
export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
const slots: Candidate[] = [];
@@ -91,9 +91,17 @@ function standsOnY(slot: Candidate): boolean {
return slot.row !== null || (slot.layerId === '' && slot.item.type !== 'tilemap');
}
/** Where a slot stands, in world Y: the bottom of an object's box, or of a map's row. */
/**
* Where a slot stands, in world Y: the bottom of an object's box, or of a map's row. A sprite whose
* frame has a sort line stands that share of its box higher up.
*/
function feetOf({ item, row }: Candidate): number {
if (row === null) return footprintExtent(item.type, item.props, item.m, item.frame).bottom;
if (row === null) {
const box = footprintExtent(item.type, item.props, item.m, item.frame);
const line = item.type === 'sprite' ? (item.texture?.sortLine ?? 0) : 0;
const frameH = item.texture?.rect?.h ?? 0;
return line > 0 && frameH > 0 ? box.bottom - (line / frameH) * (box.bottom - box.top) : box.bottom;
}
const bottom = cellOrigin(item.props, 0, row).y + tilemapTileSize(item.props);
return item.m.ty + bottom * item.m.d;
}
+114 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetFrameSortLineCommand } from '../../src/core/commands/frameCommands';
import { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands';
import {
AddTilemapLayerCommand,
@@ -7,13 +8,15 @@ import {
RenameTilemapLayerCommand,
SetTilemapLayerSortByYCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TileAsset, TilemapAsset } from '../../src/core/model/Asset';
import type { TextureAsset, TileAsset, TilemapAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { frameSortLine } from '../../src/core/model/texture';
import { TILE_SORT_OFFSET_KEY, tileSortOffset } from '../../src/core/model/tile';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { type DrawSlot, drawSlots } from '../../src/viewport/drawOrder';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { stackAt } from '../../src/viewport/pick';
import { sortLineAt, sortLineY } from '../../src/editor/texture/sheetView';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/**
@@ -193,6 +196,115 @@ describe('a tilemap layer sorted by Y (#228)', () => {
});
});
/**
* The scene above with "Box" wearing a frame of a 50 px tall picture, so one image pixel is two
* units of its 100-unit box. "Stand" has the sort line under test, "Plain" has none.
*/
function withFrames(feet: number, line: number) {
const s = makeScene(feet);
const texture = new CreateAssetCommand('texture', 'Hero');
s.f.stack.execute(texture);
const sheet = findAsset(s.f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 64;
sheet.height = 50;
const stand = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 50 }, 'Stand');
const plain = new AddFrameCommand(texture.asset.id, { x: 32, y: 0, w: 32, h: 50 }, 'Plain');
s.f.stack.execute(stand);
s.f.stack.execute(plain);
s.f.stack.execute(new SetFrameSortLineCommand(texture.asset.id, stand.frameId, line));
const wear = (frameId: string) =>
s.f.stack.execute(new SetAssetPropertyCommand(s.f.spriteId, 'texture', frameId));
wear(stand.frameId);
const frameOf = (id: string) => sheet.frames.find((fr) => fr.id === id)!;
return { ...s, textureId: texture.asset.id, stand: stand.frameId, plain: plain.frameId, wear, frameOf };
}
describe('the sort line of a texture frame (#228)', () => {
it('lifts where a sprite wearing the frame stands by its share of the box', () => {
// Feet at -50 are below row 5 (ends at -72); 15 px of 50 lift them 30 units, to -80, above it.
const lifted = withFrames(-50, 15);
lifted.sortTrees();
expect(sceneOrder(lifted)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const low = withFrames(-50, 5);
low.sortTrees();
expect(sceneOrder(low)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
});
it('belongs to the frame: another frame of the same picture stands on its bottom edge', () => {
const s = withFrames(-50, 15);
s.sortTrees();
s.wear(s.plain);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
s.wear(s.stand);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
});
it('holds in the running game', () => {
const s = withFrames(-50, 15);
s.sortTrees();
const { world } = startWorld(s.f);
expect(readable(s, drawSlots(collectWorldItems(world).items))).toEqual([
'map',
'Ground',
'box',
'Trees 5',
'Trees 8',
]);
});
it('sets, undoes and redoes, and stores 0 as nothing', () => {
const s = withFrames(0, 12);
const stand = () => s.frameOf(s.stand);
expect(stand().sortLine).toBe(12);
s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, 0));
expect('sortLine' in stand()).toBe(false);
s.f.stack.undo();
expect(stand().sortLine).toBe(12);
s.f.stack.redo();
expect('sortLine' in stand()).toBe(false);
expect('sortLine' in s.frameOf(s.plain)).toBe(false);
const fresh = withFrames(0, 0);
fresh.f.stack.execute(new SetFrameSortLineCommand(fresh.textureId, fresh.stand, 7));
fresh.f.stack.undo();
expect('sortLine' in fresh.frameOf(fresh.stand)).toBe(false);
});
it('is held to whole pixels inside the frame, also when loaded', () => {
const s = withFrames(0, 0);
for (const [raw, kept] of [
[2.6, 3],
[-4, 0],
[80, 50],
] as const) {
s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, raw));
expect(frameSortLine(s.frameOf(s.stand)), String(raw)).toBe(kept);
}
const saved = serializeProject(s.f.project) as {
assets: { id: string; frames?: Record<string, unknown>[] }[];
};
const frames = saved.assets.find((a) => a.id === s.textureId)!.frames!;
frames[0]!.sortLine = 70;
frames[1]!.sortLine = 'x';
const loaded = findAsset(deserializeProject(saved), s.textureId) as TextureAsset;
expect(loaded.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]);
const back = findAsset(roundTrip(s.f.project), s.textureId) as TextureAsset;
expect(back.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]);
});
it('is grabbed by its knob left of the frame, or on the line inside it', () => {
const rect = { x: 10, y: 20, w: 30, h: 40 };
expect(sortLineY(rect, 0)).toBe(60);
expect(sortLineY(rect, 6)).toBe(54);
expect(sortLineAt(rect, 6, { x: 4, y: 54 }, 1, 8)).toBe('knob');
expect(sortLineAt(rect, 6, { x: 25, y: 54.5 }, 1, 8)).toBe('line');
expect(sortLineAt(rect, 6, { x: 25, y: 57 }, 1, 8)).toBeNull();
expect(sortLineAt(rect, 6, { x: 0, y: 54 }, 1, 8)).toBeNull();
expect(sortLineAt(rect, 6, { x: 45, y: 54 }, 1, 8)).toBeNull();
});
});
describe('the sort offset of a tile (#228)', () => {
it('is 0 by default and held to whole rows between -16 and 16', () => {
const s = makeScene(0);
+23
View File
@@ -109,6 +109,29 @@ export async function nineSlice(t) {
);
await redo();
await redo();
// 37b-ii. The sort line (#228) sits on the bottom edge with the bottom border and the resize
// handle, so it is dragged by its knob left of the frame: 6 pixels up, one undo step.
const sortLine = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})` +
`.frames.find((f) => f.id === 'ns-frame').sortLine ?? null`,
);
const knob = (await json('__gm.texture.drawInfo()')).sortKnob;
const knobTo = await json('__gm.texture.pointAt(0, 18)');
await drag(knob.x, knob.y, knob.x, knobTo.y);
await settle();
const lifted = await sortLine();
const afterLift = await frame();
await undo();
const dropped = await sortLine();
check(
'dragging the knob sets the sort line and leaves the frame and its borders, undo takes it away',
lifted === 6 && dropped === null && afterLift.h === 24 && afterLift.border?.bottom === 0,
JSON.stringify({ knob, lifted, dropped, afterLift }),
);
check('the sort line is a row of the frame inspector', await has('Sort line'));
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');