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:
1 parent
ffb5bbe039
commit
50854389bb
16 files changed
+467
-51
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user