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; /** 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[] }[]; }; 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']); }); });