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

+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);