184 lines
7.5 KiB
TypeScript
184 lines
7.5 KiB
TypeScript
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']);
|
||
});
|
||
});
|