Let a tilemap layer sort its rows by Y with the objects (#228)

A tilemap layer was one piece in the draw order, so a figure stood
either in front of a whole layer of trees or behind all of it. For a
top-down game the trees in front of the figure's feet have to cover
it and the ones behind must not.

- A new "Sort by Y" box under the layer list sets `sortByY` on the
  painted layer. Every change is its own undo step.
- Such a layer comes as one draw slot per row. On its `z`, the rows
  and all objects on that `z` are sorted by where they stand: the
  bottom of an object's box, the bottom of a row. Ties keep the
  hierarchy order, and other `z` values are untouched.
- A layer drawn as one piece and the map's own node stay behind the
  sorted ones on the same `z`, since neither has a single place to
  stand. Ground belongs on a lower `z` or stays unsorted.
- A new tile property "Sort offset" (whole rows, -16 to 16) lets a
  crown sort with the trunk below it.
- `TilemapField` draws a container per row for such a layer, and
  picking takes the row into account, so a click picks what is drawn
  in front. Scene view, game view and play share the same rule.

Limits: on a rotated map "down" is world Y. A layer sorted by Y costs
about one draw call per row and tile instead of one per tile.

Tests:
- ./check.sh: 160 files, 2350 tests, CHECK EXIT 0 (before 2340).
- 10 new unit tests in tests/viewport/sort-by-y.test.ts.
- e2e 39-43: ALL PASSED, 148 checks. New check 40p-iii ticks the box
  and finds the slot of row 0, undo brings back the whole layer.
- Counter checks: without the Y sort 5 unit tests fail, with the
  offset ignored 5, with no row in picking 1. If TilemapField draws
  no rows, exactly 40p-iii fails in e2e.
- The e2e sum in the README is 1378, 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:32:37 +02:00
1 parent ffb5bbe039
commit 50854389bb
16 files changed
+467 -51

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands';
import {
AddTilemapLayerCommand,
PaintTilesCommand,
RenameTilemapLayerCommand,
SetTilemapLayerSortByYCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TileAsset, TilemapAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
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 { 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']);
});
});
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);
});
});