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
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
setFrameCollision,
|
setFrameCollision,
|
||||||
setFrameRect,
|
setFrameRect,
|
||||||
setFrameSlice,
|
setFrameSlice,
|
||||||
|
setFrameSortLine,
|
||||||
sliceFrames,
|
sliceFrames,
|
||||||
type SliceOptions,
|
type SliceOptions,
|
||||||
uniqueFrameName,
|
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,
|
* 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
|
* starting or removing an outline are each one edit, and the outlines to revert to are read at
|
||||||
|
|||||||
@@ -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.
|
* – its bordered tile and its notches. Absent is none; one of fewer than three corners collides as none.
|
||||||
*/
|
*/
|
||||||
collision?: Vec2[][];
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface TextureRef {
|
|||||||
sourceHeight: number;
|
sourceHeight: number;
|
||||||
/** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */
|
/** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */
|
||||||
slice: NineSlice | null;
|
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 {
|
function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null {
|
||||||
if (!isTextureFile(asset.file)) return null;
|
if (!isTextureFile(asset.file)) return null;
|
||||||
|
const sortLine = frame ? frameSortLine(frame) : 0;
|
||||||
return {
|
return {
|
||||||
file: asset.file,
|
file: asset.file,
|
||||||
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
|
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
|
||||||
@@ -126,9 +129,28 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
|
|||||||
sourceWidth: asset.width,
|
sourceWidth: asset.width,
|
||||||
sourceHeight: asset.height,
|
sourceHeight: asset.height,
|
||||||
slice: frame ? nineSliceOf(frame, sliceScaleOf(asset)) : null,
|
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 };
|
const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||||
export const DEFAULT_FILL: FrameFill = 'repeat';
|
export const DEFAULT_FILL: FrameFill = 'repeat';
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
normalizeFrameRect,
|
normalizeFrameRect,
|
||||||
setFrameCollision,
|
setFrameCollision,
|
||||||
setFrameSlice,
|
setFrameSlice,
|
||||||
|
setFrameSortLine,
|
||||||
} from '../model/texture';
|
} from '../model/texture';
|
||||||
import { pointsOf } from '../model/polygon';
|
import { pointsOf } from '../model/polygon';
|
||||||
import { canvasSide, PALETTE_MAX } from '../model/pixelart';
|
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).
|
// 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);
|
const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3);
|
||||||
if (outlines.length > 0) setFrameCollision(frame, outlines);
|
if (outlines.length > 0) setFrameCollision(frame, outlines);
|
||||||
|
setFrameSortLine(frame, toNumber(f.sortLine));
|
||||||
frames.push(frame);
|
frames.push(frame);
|
||||||
}
|
}
|
||||||
return { file, width, height, frames };
|
return { file, width, height, frames };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
RenameFrameCommand,
|
RenameFrameCommand,
|
||||||
SetFrameRectCommand,
|
SetFrameRectCommand,
|
||||||
SetFrameSliceCommand,
|
SetFrameSliceCommand,
|
||||||
|
SetFrameSortLineCommand,
|
||||||
} from '../../core/commands/frameCommands';
|
} from '../../core/commands/frameCommands';
|
||||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||||
@@ -15,9 +16,12 @@ import {
|
|||||||
type FrameRect,
|
type FrameRect,
|
||||||
frameSlice,
|
frameSlice,
|
||||||
type FrameSlice,
|
type FrameSlice,
|
||||||
|
frameSortLine,
|
||||||
normalizeFrameRect,
|
normalizeFrameRect,
|
||||||
|
normalizeSortLine,
|
||||||
setFrameRect,
|
setFrameRect,
|
||||||
setFrameSlice,
|
setFrameSlice,
|
||||||
|
setFrameSortLine,
|
||||||
} from '../../core/model/texture';
|
} from '../../core/model/texture';
|
||||||
import { Button } from '../../ui';
|
import { Button } from '../../ui';
|
||||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||||
@@ -70,6 +74,10 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId:
|
|||||||
fillRow(host, assetId, frameId);
|
fillRow(host, assetId, frameId);
|
||||||
addNote(host, 'All four at zero: the picture is drawn over the whole box, as it always was.');
|
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);
|
addGap(host);
|
||||||
host.body.add(
|
host.body.add(
|
||||||
new Button('Delete frame', {
|
new Button('Delete frame', {
|
||||||
@@ -89,9 +97,28 @@ export function refreshFrameSection(host: InspectorHost, assetId: string, frameI
|
|||||||
for (const component of BORDER_COMPONENTS)
|
for (const component of BORDER_COMPONENTS)
|
||||||
host.fields.get(`border-${component.key}`)?.setValue(border[component.key]);
|
host.fields.get(`border-${component.key}`)?.setValue(border[component.key]);
|
||||||
host.fields.get('fill')?.setValue(frameFill(frame));
|
host.fields.get('fill')?.setValue(frameFill(frame));
|
||||||
|
host.fields.get('sortLine')?.setValue(frameSortLine(frame));
|
||||||
return true;
|
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
|
* 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.
|
* as they are at that moment, so two rows edited one after the other do not undo each other.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
SetFrameCollisionCommand,
|
SetFrameCollisionCommand,
|
||||||
SetFrameRectCommand,
|
SetFrameRectCommand,
|
||||||
SetFrameSliceCommand,
|
SetFrameSliceCommand,
|
||||||
|
SetFrameSortLineCommand,
|
||||||
} from '../../core/commands/frameCommands';
|
} from '../../core/commands/frameCommands';
|
||||||
import { polygonProblem } from '../../core/model/polygon';
|
import { polygonProblem } from '../../core/model/polygon';
|
||||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||||
@@ -14,12 +15,15 @@ import {
|
|||||||
frameCollision,
|
frameCollision,
|
||||||
frameFill,
|
frameFill,
|
||||||
type FrameRect,
|
type FrameRect,
|
||||||
|
frameSortLine,
|
||||||
MIN_FRAME_SIZE,
|
MIN_FRAME_SIZE,
|
||||||
normalizeFrameRect,
|
normalizeFrameRect,
|
||||||
|
normalizeSortLine,
|
||||||
rawFrameSlice,
|
rawFrameSlice,
|
||||||
restoreFrameSlice,
|
restoreFrameSlice,
|
||||||
setFrameCollision,
|
setFrameCollision,
|
||||||
setFrameSlice,
|
setFrameSlice,
|
||||||
|
setFrameSortLine,
|
||||||
} from '../../core/model/texture';
|
} from '../../core/model/texture';
|
||||||
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
|
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +59,8 @@ import {
|
|||||||
localToImage,
|
localToImage,
|
||||||
rectBetween,
|
rectBetween,
|
||||||
type SheetView,
|
type SheetView,
|
||||||
|
sortLineAt,
|
||||||
|
sortLineY,
|
||||||
} from './sheetView';
|
} from './sheetView';
|
||||||
|
|
||||||
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
|
/** 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. */
|
/** Inset of a frame's name plate from the frame's top-left corner, and its own padding. */
|
||||||
const LABEL_INSET = 3;
|
const LABEL_INSET = 3;
|
||||||
const LABEL_PAD = 2;
|
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 {
|
interface FrameDrag {
|
||||||
frameId: string;
|
frameId: string;
|
||||||
handle: FrameHandle | null;
|
handle: FrameHandle | null;
|
||||||
edge: BorderEdge | 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;
|
start: FrameRect;
|
||||||
startBorder: FrameBorders;
|
startBorder: FrameBorders;
|
||||||
/** The slice keys as they were, so the command records the state that was really there. */
|
/** 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,
|
sheetOutlines: this.sheetOutlines,
|
||||||
filled: { sheet: this.sheetFilled, overlay: this.overlayFilled },
|
filled: { sheet: this.sheetFilled, overlay: this.overlayFilled },
|
||||||
origin: { x: origin.x, y: origin.y },
|
origin: { x: origin.x, y: origin.y },
|
||||||
|
sortKnob: this.sortKnob(origin),
|
||||||
labels: [...this.labels.values()]
|
labels: [...this.labels.values()]
|
||||||
.filter((label) => label.visible)
|
.filter((label) => label.visible)
|
||||||
.map((label) => ({ text: label.text, ...label.getGlobalPosition() })),
|
.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 {
|
private asset(): TextureAsset | null {
|
||||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||||
return asset && asset.type === 'texture' ? asset : null;
|
return asset && asset.type === 'texture' ? asset : null;
|
||||||
@@ -256,6 +281,12 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
const selected = this.frame(this.selectedFrameId());
|
const selected = this.frame(this.selectedFrameId());
|
||||||
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
||||||
const onBorder = selected ? borderAt(selected, frameBorders(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') {
|
if (selected && onSelected && onSelected !== 'move') {
|
||||||
this.startDrag(selected, onSelected, null, local);
|
this.startDrag(selected, onSelected, null, local);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -266,6 +297,11 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selected && onSortLine) {
|
||||||
|
this.startDrag(selected, null, null, local, true);
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selected && onSelected) {
|
if (selected && onSelected) {
|
||||||
this.startDrag(selected, onSelected, null, local);
|
this.startDrag(selected, onSelected, null, local);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -386,11 +422,14 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
handle: FrameHandle | null,
|
handle: FrameHandle | null,
|
||||||
edge: BorderEdge | null,
|
edge: BorderEdge | null,
|
||||||
local: Vec2,
|
local: Vec2,
|
||||||
|
sortLine = false,
|
||||||
): void {
|
): void {
|
||||||
this.dragging = {
|
this.dragging = {
|
||||||
frameId: frame.id,
|
frameId: frame.id,
|
||||||
handle,
|
handle,
|
||||||
edge,
|
edge,
|
||||||
|
sortLine,
|
||||||
|
startSortLine: frame.sortLine,
|
||||||
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
|
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
|
||||||
startBorder: frameBorders(frame),
|
startBorder: frameBorders(frame),
|
||||||
startSlice: rawFrameSlice(frame),
|
startSlice: rawFrameSlice(frame),
|
||||||
@@ -448,7 +487,8 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
const frame = drag && this.frame(drag.frameId);
|
const frame = drag && this.frame(drag.frameId);
|
||||||
if (!drag || !asset || !frame) return;
|
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, {
|
setFrameSlice(frame, {
|
||||||
border: dragBorder(drag.startBorder, drag.edge, dx, dy),
|
border: dragBorder(drag.startBorder, drag.edge, dx, dy),
|
||||||
fill: frameFill(frame),
|
fill: frameFill(frame),
|
||||||
@@ -483,7 +523,12 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
const frame = this.frame(drag.frameId);
|
const frame = this.frame(drag.frameId);
|
||||||
if (drag.moved && asset && frame) {
|
if (drag.moved && asset && frame) {
|
||||||
// The preview moved it already; put it back so the command records the real start.
|
// 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) };
|
const next = { border: frameBorders(frame), fill: frameFill(frame) };
|
||||||
restoreFrameSlice(frame, drag.startSlice);
|
restoreFrameSlice(frame, drag.startSlice);
|
||||||
this.state.execute(new SetFrameSliceCommand(asset.id, drag.frameId, next));
|
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 tolerance = HANDLE_SIZE / this.view.zoom;
|
||||||
const selected = this.frame(this.selectedFrameId());
|
const selected = this.frame(this.selectedFrameId());
|
||||||
const handle = selected ? handleAt(selected, point, tolerance) : null;
|
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') {
|
if (handle && handle !== 'move') {
|
||||||
this.cursor = handleCursor(handle);
|
this.cursor = handleCursor(handle);
|
||||||
return;
|
return;
|
||||||
@@ -536,6 +586,10 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
this.cursor = borderCursor(edge);
|
this.cursor = borderCursor(edge);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (onSortLine) {
|
||||||
|
this.cursor = 'ns-resize';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (handle) {
|
if (handle) {
|
||||||
this.cursor = handleCursor(handle);
|
this.cursor = handleCursor(handle);
|
||||||
return;
|
return;
|
||||||
@@ -622,6 +676,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
this.overlayFilled = this.drawOutlines(this.overlay, view, selected, this.collision, true);
|
this.overlayFilled = this.drawOutlines(this.overlay, view, selected, this.collision, true);
|
||||||
if (!this.collision) {
|
if (!this.collision) {
|
||||||
this.drawBorders(view, selected);
|
this.drawBorders(view, selected);
|
||||||
|
this.drawSortLine(view, selected);
|
||||||
this.drawHandles(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 });
|
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
|
* 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
|
* the edited frame the active outline is drawn heavier and every corner of every outline gets a
|
||||||
|
|||||||
@@ -167,6 +167,29 @@ export function dragBorder(border: FrameBorders, edge: BorderEdge, dx: number, d
|
|||||||
return moved;
|
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 {
|
export function borderCursor(edge: BorderEdge): string {
|
||||||
return edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize';
|
return edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ interface Candidate extends DrawSlot {
|
|||||||
* piece. Symbols are left out, they are drawn above everything.
|
* 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
|
* 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
|
* (#228): an object by the bottom of its box or by its frame's sort line, a row of the layer by the
|
||||||
* keep the order above. A layer drawn as one piece and the map's own node stay behind them, in
|
* bottom of the row. Ties keep the order above. A layer drawn as one piece and the map's own node
|
||||||
* that order, since neither has a single place to stand.
|
* stay behind them, in that order, since neither has a single place to stand.
|
||||||
*/
|
*/
|
||||||
export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
|
export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
|
||||||
const slots: Candidate[] = [];
|
const slots: Candidate[] = [];
|
||||||
@@ -91,9 +91,17 @@ function standsOnY(slot: Candidate): boolean {
|
|||||||
return slot.row !== null || (slot.layerId === '' && slot.item.type !== 'tilemap');
|
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 {
|
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);
|
const bottom = cellOrigin(item.props, 0, row).y + tilemapTileSize(item.props);
|
||||||
return item.m.ty + bottom * item.m.d;
|
return item.m.ty + bottom * item.m.d;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||||
import {
|
import {
|
||||||
AddTilemapLayerCommand,
|
AddTilemapLayerCommand,
|
||||||
@@ -7,13 +8,15 @@ import {
|
|||||||
RenameTilemapLayerCommand,
|
RenameTilemapLayerCommand,
|
||||||
SetTilemapLayerSortByYCommand,
|
SetTilemapLayerSortByYCommand,
|
||||||
} from '../../src/core/commands/tilemapCommands';
|
} 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 { 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 { TILE_SORT_OFFSET_KEY, tileSortOffset } from '../../src/core/model/tile';
|
||||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||||
import { type DrawSlot, drawSlots } from '../../src/viewport/drawOrder';
|
import { type DrawSlot, drawSlots } from '../../src/viewport/drawOrder';
|
||||||
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
||||||
import { stackAt } from '../../src/viewport/pick';
|
import { stackAt } from '../../src/viewport/pick';
|
||||||
|
import { sortLineAt, sortLineY } from '../../src/editor/texture/sheetView';
|
||||||
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
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)', () => {
|
describe('the sort offset of a tile (#228)', () => {
|
||||||
it('is 0 by default and held to whole rows between -16 and 16', () => {
|
it('is 0 by default and held to whole rows between -16 and 16', () => {
|
||||||
const s = makeScene(0);
|
const s = makeScene(0);
|
||||||
|
|||||||
@@ -109,6 +109,29 @@ export async function nineSlice(t) {
|
|||||||
);
|
);
|
||||||
await redo();
|
await redo();
|
||||||
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(`
|
await mutate(`
|
||||||
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
|
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
|
||||||
.frames.find((f) => f.id === 'ns-frame');
|
.frames.find((f) => f.id === 'ns-frame');
|
||||||
|
|||||||
Reference in new issue
Block a user