Each tilemap layer has its own Z: an offset added to the Z of the placed tilemap, like a child's Z to its parent, default 0 keeps the previous order (layers right after their map, in list order), a negative Z sorts a layer below the map, on a tie layers keep their list order and follow the hierarchy against other objects; editor scene and game use the same order through the new pure function drawSlots, a tilemap draws into one container per layer, and the tilemap panel gains a Z field for the selected layer with one undo step per change (#174)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5385d9e152
commit
47eea2f6e4
13 files changed
+389
-38
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
AddTilemapLayerCommand,
|
||||
RenameTilemapLayerCommand,
|
||||
SetTilemapLayerHiddenCommand,
|
||||
SetTilemapLayerZCommand,
|
||||
} from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { layerZ } from '../../src/core/model/tilemap';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { drawSlots } from '../../src/viewport/drawOrder';
|
||||
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
||||
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
/**
|
||||
* A scene with a tilemap of two layers, "Ground" and "Roofs", and the sprite "Box" – each placed
|
||||
* at the `z` asked for, the map first in the hierarchy.
|
||||
*/
|
||||
function makeScene(mapZ: number, boxZ: number) {
|
||||
const f = makeSpriteScene('Box');
|
||||
const map = new CreateAssetCommand('tilemap', 'Village');
|
||||
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 roofsAdd = new AddTilemapLayerCommand(mapId);
|
||||
f.stack.execute(roofsAdd);
|
||||
f.stack.execute(new RenameTilemapLayerCommand(mapId, roofsAdd.layerId, 'Roofs'));
|
||||
const mapObject = place(f, mapId, { x: 0, y: 0, z: mapZ });
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0, z: boxZ });
|
||||
const names = new Map([
|
||||
[mapObject, 'map'],
|
||||
[box, 'box'],
|
||||
]);
|
||||
const layerNames = new Map([
|
||||
[ground, 'Ground'],
|
||||
[roofsAdd.layerId, 'Roofs'],
|
||||
]);
|
||||
return { f, mapId, mapObject, box, ground, roofs: roofsAdd.layerId, assetOf, names, layerNames };
|
||||
}
|
||||
|
||||
type Scene = ReturnType<typeof makeScene>;
|
||||
|
||||
/** The draw order of the scene view, back to front, as readable names. */
|
||||
function sceneOrder(s: Scene): string[] {
|
||||
return readable(s, drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items));
|
||||
}
|
||||
|
||||
function readable(s: Scene, slots: { id: string; layerId: string }[]): string[] {
|
||||
return slots.map((slot) =>
|
||||
slot.layerId ? s.layerNames.get(slot.layerId)! : (s.names.get(slot.id) ?? slot.id),
|
||||
);
|
||||
}
|
||||
|
||||
describe('the z of a tilemap layer (#174)', () => {
|
||||
it('sets, undoes and redoes, and stores 0 as nothing', () => {
|
||||
const s = makeScene(0, 0);
|
||||
const roofs = () => s.assetOf().layers[1]!;
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
expect(roofs().z).toBe(10);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, -2.5));
|
||||
expect(roofs().z).toBe(-2.5);
|
||||
s.f.stack.undo();
|
||||
expect(roofs().z).toBe(10);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 0));
|
||||
expect('z' in roofs()).toBe(false);
|
||||
expect(layerZ(roofs())).toBe(0);
|
||||
s.f.stack.undo();
|
||||
s.f.stack.undo();
|
||||
expect('z' in roofs()).toBe(false);
|
||||
s.f.stack.redo();
|
||||
expect(roofs().z).toBe(10);
|
||||
});
|
||||
|
||||
it('refuses what is not a number', () => {
|
||||
const s = makeScene(0, 0);
|
||||
for (const z of [Number.NaN, Number.POSITIVE_INFINITY])
|
||||
expect(() => s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, z))).toThrow(
|
||||
'A layer z is a number',
|
||||
);
|
||||
expect('z' in s.assetOf().layers[1]!).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a round trip, and loading keeps only a finite number other than 0', () => {
|
||||
const s = makeScene(0, 0);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
const back = findAsset(roundTrip(s.f.project), s.mapId) as TilemapAsset;
|
||||
expect(back.layers.map((l) => l.z)).toEqual([undefined, 10]);
|
||||
|
||||
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 [
|
||||
[0, undefined],
|
||||
['5', undefined],
|
||||
[null, undefined],
|
||||
[true, undefined],
|
||||
[-3, -3],
|
||||
] as const) {
|
||||
layers[1]!.z = raw;
|
||||
const loaded = findAsset(deserializeProject(saved), s.mapId) as TilemapAsset;
|
||||
expect(loaded.layers[1]!.z, `stored ${String(raw)}`).toBe(kept);
|
||||
}
|
||||
});
|
||||
|
||||
it('with every layer at 0 draws as before: the layers right after their map, in list order', () => {
|
||||
expect(sceneOrder(makeScene(0, 0))).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
expect(sceneOrder(makeScene(1, 0))).toEqual(['box', 'map', 'Ground', 'Roofs']);
|
||||
});
|
||||
|
||||
it('adds to the map’s own z, so a figure walks between the ground and the roofs', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box', 'Roofs']);
|
||||
});
|
||||
|
||||
it('moves every layer along when the map itself is moved in z – it is relative, not absolute', () => {
|
||||
const s = makeScene(20, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
// Roofs at 30 and Ground at 20 are both above the box at 5, still ten apart.
|
||||
expect(sceneOrder(s)).toEqual(['box', 'map', 'Ground', 'Roofs']);
|
||||
});
|
||||
|
||||
it('sorts a layer with a negative z below its map and below what shares the map’s z', () => {
|
||||
const s = makeScene(0, 0);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, -1));
|
||||
expect(sceneOrder(s)).toEqual(['Roofs', 'map', 'Ground', 'box']);
|
||||
});
|
||||
|
||||
it('breaks a tie of z by the hierarchy, and between layers by their list order', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.ground, 5));
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 5));
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
});
|
||||
|
||||
it('leaves the symbols out, whatever their z: they are drawn above everything', () => {
|
||||
const s = makeScene(0, 5);
|
||||
const camera = new CreateAssetCommand('camera', 'Camera');
|
||||
s.f.stack.execute(camera);
|
||||
place(s.f, camera.asset.id, { x: 0, y: 0, z: 7 });
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
});
|
||||
|
||||
it('leaves a hidden layer out of the order', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
s.f.stack.execute(new SetTilemapLayerHiddenCommand(s.mapId, s.roofs, true));
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box']);
|
||||
});
|
||||
|
||||
it('gives two placements of one map the same steps from their own z', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
const high = place(s.f, s.mapId, { x: 0, y: 0, z: 100 });
|
||||
s.names.set(high, 'high');
|
||||
const slots = drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items);
|
||||
expect(slots.map((slot) => [readable(s, [slot])[0], slot.z])).toEqual([
|
||||
['map', 0],
|
||||
['Ground', 0],
|
||||
['box', 5],
|
||||
['Roofs', 10],
|
||||
['high', 100],
|
||||
['Ground', 100],
|
||||
['Roofs', 110],
|
||||
]);
|
||||
});
|
||||
|
||||
it('holds in the running game too, and follows the map when a run changes its z', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
const { world } = startWorld(s.f);
|
||||
const gameOrder = () => readable(s, drawSlots(collectWorldItems(world).items));
|
||||
expect(gameOrder()).toEqual(['map', 'Ground', 'box', 'Roofs']);
|
||||
const map = world.entities.find((e) => e.objectId === s.mapObject)!;
|
||||
map.z = -20;
|
||||
expect(gameOrder()).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user