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>
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
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);
|
||
});
|
||
});
|