Files
feuerwerk/tests/viewport/sort-by-y.test.ts
T
adminandClaude Opus 5.5 89048413f1 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>
2026-09-24 19:34:37 +02:00

324 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
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,
PaintTilesCommand,
RenameTilemapLayerCommand,
SetTilemapLayerSortByYCommand,
} from '../../src/core/commands/tilemapCommands';
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';
/**
* A 20 × 15 map of 48-unit cells at the origin, so row `r` ends at `-360 + (r + 1) * 48`: row 5 at
* -72, row 8 at 72. "Ground" is a plain layer; "Trees" holds a trunk at (2,5), its crown at (2,4)
* and a bush at (7,8). The sprite "Box" is 100 tall and stands with its bottom at `feet`.
*/
function makeScene(feet: number, crownOffset = 1) {
const f = makeSpriteScene('Box');
const map = new CreateAssetCommand('tilemap', 'Forest');
f.stack.execute(map);
const mapId = map.asset.id;
const assetOf = () => findAsset(f.project, mapId) as TilemapAsset;
const ground = assetOf().layers[0]!.id;
f.stack.execute(new RenameTilemapLayerCommand(mapId, ground, 'Ground'));
const treesAdd = new AddTilemapLayerCommand(mapId);
f.stack.execute(treesAdd);
const trees = treesAdd.layerId;
f.stack.execute(new RenameTilemapLayerCommand(mapId, trees, 'Trees'));
const tile = (name: string, props = {}) => {
const created = new CreateAssetCommand('tile', name, props);
f.stack.execute(created);
return created.asset.id;
};
const trunk = tile('Trunk');
const crown = tile('Crown', { [TILE_SORT_OFFSET_KEY]: crownOffset });
f.stack.execute(new PaintTilesCommand(mapId, ground, [{ x: 0, y: 0, tileId: trunk }]));
f.stack.execute(
new PaintTilesCommand(mapId, trees, [
{ x: 2, y: 5, tileId: trunk },
{ x: 2, y: 4, tileId: crown },
{ x: 7, y: 8, tileId: trunk },
]),
);
const mapObject = place(f, mapId, { x: 0, y: 0, z: 0 });
const box = place(f, f.spriteId, { x: 0, y: feet - 50, z: 0 });
const names = new Map([
[mapObject, 'map'],
[box, 'box'],
]);
const layerNames = new Map([
[ground, 'Ground'],
[trees, 'Trees'],
]);
const sortTrees = () => f.stack.execute(new SetTilemapLayerSortByYCommand(mapId, trees, true));
return { f, mapId, mapObject, box, ground, trees, crown, assetOf, names, layerNames, sortTrees };
}
type Scene = ReturnType<typeof makeScene>;
function readable(s: Scene, slots: DrawSlot[]): string[] {
return slots.map((slot) => {
if (!slot.layerId) return s.names.get(slot.id) ?? slot.id;
const name = s.layerNames.get(slot.layerId)!;
return slot.row === null ? name : `${name} ${slot.row}`;
});
}
function sceneOrder(s: Scene): string[] {
return readable(s, drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items));
}
describe('a tilemap layer sorted by Y (#228)', () => {
it('is off by default, and the map then draws as one piece per layer as before', () => {
const s = makeScene(-50);
expect(s.assetOf().layers.map((l) => l.sortByY)).toEqual([undefined, undefined]);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees', 'box']);
});
it('sets, undoes and redoes, and stores off as nothing', () => {
const s = makeScene(-50);
const trees = () => s.assetOf().layers[1]!;
s.sortTrees();
expect(trees().sortByY).toBe(true);
s.f.stack.execute(new SetTilemapLayerSortByYCommand(s.mapId, s.trees, false));
expect('sortByY' in trees()).toBe(false);
s.f.stack.undo();
expect(trees().sortByY).toBe(true);
s.f.stack.undo();
expect('sortByY' in trees()).toBe(false);
s.f.stack.redo();
expect(trees().sortByY).toBe(true);
});
it('survives a round trip, and loading keeps only a literal true', () => {
const s = makeScene(-50);
s.sortTrees();
const back = findAsset(roundTrip(s.f.project), s.mapId) as TilemapAsset;
expect(back.layers.map((l) => l.sortByY)).toEqual([undefined, true]);
const saved = serializeProject(s.f.project) as {
assets: { id: string; layers?: Record<string, unknown>[] }[];
};
const layers = saved.assets.find((a) => a.id === s.mapId)!.layers!;
for (const [raw, kept] of [
[true, true],
['true', undefined],
[1, undefined],
[false, undefined],
] as const) {
layers[1]!.sortByY = raw;
const loaded = findAsset(deserializeProject(saved), s.mapId) as TilemapAsset;
expect(loaded.layers[1]!.sortByY, `stored ${String(raw)}`).toBe(kept);
}
});
it('puts a figure in front of a row whose bottom is above its feet, behind one below', () => {
const between = makeScene(-50);
between.sortTrees();
expect(sceneOrder(between)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
const behind = makeScene(-100);
behind.sortTrees();
expect(sceneOrder(behind)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const front = makeScene(100);
front.sortTrees();
expect(sceneOrder(front)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']);
});
it('sorts a crown with the trunk its sort offset points to, and on its own row without one', () => {
const offset = makeScene(-80);
offset.sortTrees();
// Feet at -80: below the crown's own row (ends at -120), above the trunk's (ends at -72).
expect(sceneOrder(offset)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const plain = makeScene(-80, 0);
plain.sortTrees();
expect(sceneOrder(plain)).toEqual(['map', 'Ground', 'Trees 4', 'box', 'Trees 5', 'Trees 8']);
});
it('sorts objects on that z among each other by their feet, too', () => {
const s = makeScene(100);
s.sortTrees();
const far = place(s.f, s.f.spriteId, { x: 0, y: -150, z: 0 });
s.names.set(far, 'far');
// `far` comes after `box` in the hierarchy but stands further up, so it is drawn first.
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'far', 'Trees 5', 'Trees 8', 'box']);
});
it('leaves everything on another z as it was', () => {
const s = makeScene(-50);
s.sortTrees();
s.f.stack.execute(new MoveSceneObjectCommand(s.f.sceneId, s.box, { x: 0, y: -100, z: 3 }));
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']);
});
it('holds in the running game, and follows a figure that walks past a row', () => {
const s = makeScene(-100);
s.sortTrees();
const { world } = startWorld(s.f);
const gameOrder = () => readable(s, drawSlots(collectWorldItems(world).items));
expect(gameOrder()).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
world.entities.find((e) => e.objectId === s.box)!.y = 0;
expect(gameOrder()).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
});
it('picks the figure over a row behind it and the row over a figure behind it', () => {
const cellHit = (s: Scene) => ({
id: s.mapObject,
layers: [s.ground, s.trees],
rows: new Map([[s.trees, 5]]),
});
const pick = (s: Scene) =>
stackAt(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items, [
{ id: s.box, layers: null },
cellHit(s),
]).map((id) => s.names.get(id));
const inFront = makeScene(-50);
inFront.sortTrees();
expect(pick(inFront)).toEqual(['box', 'map']);
const behind = makeScene(-100);
behind.sortTrees();
expect(pick(behind)).toEqual(['map', 'box']);
});
});
/**
* 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);
const crown = () => findAsset(s.f.project, s.crown) as TileAsset;
expect(tileSortOffset({ ...crown(), props: {} })).toBe(0);
for (const [raw, kept] of [
[1, 1],
[2.4, 2],
[-3, -3],
[40, 16],
[-40, -16],
['x', 0],
] as const)
expect(tileSortOffset({ ...crown(), props: { [TILE_SORT_OFFSET_KEY]: raw } }), String(raw)).toBe(kept);
});
});