From 89048413f188646b59efd2f0b0b325950d9300a4 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 19:34:37 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/frameCommands.ts | 26 ++++++ src/core/model/Asset.ts | 5 ++ src/core/model/texture.ts | 22 +++++ src/core/store/serialize.ts | 2 + src/editor/inspector/frameSection.ts | 27 +++++++ src/editor/texture/TextureCanvas.ts | 81 ++++++++++++++++++- src/editor/texture/sheetView.ts | 23 ++++++ src/viewport/drawOrder.ts | 18 +++-- tests/viewport/sort-by-y.test.ts | 116 ++++++++++++++++++++++++++- tools/e2e/37-nine-slice.mjs | 23 ++++++ 11 files changed, 334 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7ee97b7a..2ad63a54 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts index 9851dcdc..c6a3c5f5 100644 --- a/src/core/commands/frameCommands.ts +++ b/src/core/commands/frameCommands.ts @@ -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 diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 5b06fd24..dd25b6b6 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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; } /** diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 915b974d..60d71c97 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -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'; diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 2911736f..4ab064e1 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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 }; diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts index 8f9f4f7d..3ba3cd52 100644 --- a/src/editor/inspector/frameSection.ts +++ b/src/editor/inspector/frameSection.ts @@ -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(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. diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 292e8636..850fc43f 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 diff --git a/src/editor/texture/sheetView.ts b/src/editor/texture/sheetView.ts index b1b920c9..f1091601 100644 --- a/src/editor/texture/sheetView.ts +++ b/src/editor/texture/sheetView.ts @@ -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'; } diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index 5eb91fa8..9f005aca 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -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; } diff --git a/tests/viewport/sort-by-y.test.ts b/tests/viewport/sort-by-y.test.ts index ebeeddec..1bcc2caf 100644 --- a/tests/viewport/sort-by-y.test.ts +++ b/tests/viewport/sort-by-y.test.ts @@ -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[] }[]; + }; + 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); diff --git a/tools/e2e/37-nine-slice.mjs b/tools/e2e/37-nine-slice.mjs index 82e63e0e..fcbe5731 100644 --- a/tools/e2e/37-nine-slice.mjs +++ b/tools/e2e/37-nine-slice.mjs @@ -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');