diff --git a/README.md b/README.md index d4ce1f2a..58a28ca9 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1075 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1076 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index bbf8aedc..66527f1f 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -245,6 +245,34 @@ export class RenameTilemapLayerCommand implements Command { } } +/** A layer's `z` above its tilemap's (#174); 0 is stored as nothing, the way a layer starts out. */ +export class SetTilemapLayerZCommand implements Command { + readonly label = 'Set layer z'; + private previous: number | undefined; + + constructor( + private readonly assetId: string, + private readonly layerId: string, + private readonly z: number, + ) {} + + execute(project: Project): void { + if (!Number.isFinite(this.z)) throw new CommandError('A layer z is a number'); + const layer = requireTilemapLayer(project, this.assetId, this.layerId); + this.previous = layer.z; + setLayerZ(layer, this.z); + } + + undo(project: Project): void { + setLayerZ(requireTilemapLayer(project, this.assetId, this.layerId), this.previous ?? 0); + } +} + +function setLayerZ(layer: TilemapLayer, z: number): void { + if (z === 0) delete layer.z; + else layer.z = z; +} + /** A hidden layer keeps its cells and is left out of the drawing, in the editor and in the game alike. */ export class SetTilemapLayerHiddenCommand implements Command { readonly label: string; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 46c81392..dd942ca4 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -213,12 +213,15 @@ export interface TileAsset extends AssetBase { /** * One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the tile lying there, and a * layer nobody switched off is drawn. Which of that tile's quarters a cell shows is worked out - * from its neighbours while drawing, never stored – see `model/tilemap.ts`. + * from its neighbours while drawing, never stored – see `model/tilemap.ts`. `z` is added to the `z` + * of the placed tilemap (#174), as a child's is to its parent's, so every placement of the map + * keeps its layers apart by the same steps; layers of equal `z` keep their list order. */ export interface TilemapLayer { id: string; name: string; hidden?: boolean; + z?: number; cells: Record; } diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 8f70a6b5..1186136a 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -72,6 +72,11 @@ export function isLayerVisible(layer: TilemapLayer): boolean { return layer.hidden !== true; } +/** How far a layer is drawn above its tilemap's own `z` (#174); a layer never given one sits on it. */ +export function layerZ(layer: TilemapLayer): number { + return layer.z ?? 0; +} + export function findLayer(asset: { layers: TilemapLayer[] }, layerId: string): TilemapLayer | undefined { return asset.layers.find((layer) => layer.id === layerId); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 908c52a9..d43a8ffa 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -546,6 +546,7 @@ function normalizeLayers(raw: unknown): TilemapLayer[] { if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value; const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells }; if (l.hidden === true) layer.hidden = true; + if (typeof l.z === 'number' && Number.isFinite(l.z) && l.z !== 0) layer.z = l.z; layers.push(layer); } return layers; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 193f13bb..47411f56 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -4,7 +4,7 @@ import { previewObjectId } from '../core/model/animationPreview'; import { resolveObjects } from '../core/model/instance'; import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; -import { findLayer } from '../core/model/tilemap'; +import { findLayer, layerZ } from '../core/model/tilemap'; import { effectiveScale, gameClock } from '../core/time'; import { physicsStatus } from '../runtime/physics/engine'; import { @@ -554,6 +554,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E id: layer.id, name: layer.name, hidden: layer.hidden === true, + z: layerZ(layer), cells: Object.keys(layer.cells).length, })) ?? null, /** What the scene view drew: meshes (one draw call each), cells and visible layers. */ @@ -598,6 +599,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const field = currentTilemapWidgets()?.layerName; return field ? { ...centreOf(field), value: field.value } : null; }, + /** The Z field of the painted layer, with what it holds (#174). */ + layerZAt: () => { + const field = currentTilemapWidgets()?.layerZ; + return field ? { ...centreOf(field), value: field.value } : null; + }, focusAt: () => { const button = currentTilemapWidgets()?.focus; return button ? { ...centreOf(button), label: button.text } : null; diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index 98184696..ff1c8d9e 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -13,19 +13,21 @@ import { RemoveTilemapLayerCommand, RenameTilemapLayerCommand, SetTilemapLayerHiddenCommand, + SetTilemapLayerZCommand, } from '../../core/commands/tilemapCommands'; import type { Command } from '../../core/commands/Command'; import type { TilemapAsset } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; import { findAssetOf } from '../../core/model/Project'; -import { findLayer, isLayerVisible } from '../../core/model/tilemap'; +import { findLayer, isLayerVisible, layerZ } from '../../core/model/tilemap'; import { Button, Checkbox, Dropdown, HStack, Menu, + NumberField, Panel, ScrollContainer, TextField, @@ -134,6 +136,8 @@ export interface TilemapPanelWidgets { layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[]; /** The name of the layer painted on (#138). */ layerName: TextField; + /** Its `z` above the map's own (#174). */ + layerZ: NumberField; /** How the other layers are shown in the scene view (#138). */ focus: Dropdown; addLayer: Button; @@ -236,7 +240,8 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { const { state } = host; const paint = state.tilemapPaint; const painted = paintedLayerId(state, asset); - const paintedName = findLayer(asset, painted)?.name ?? ''; + const paintedLayer = findLayer(asset, painted); + const paintedName = paintedLayer?.name ?? ''; const layerName: TextField = new TextField(paintedName, { onChange: (name) => { // The command rejects a blank name but cannot put the old text back into the field, @@ -245,6 +250,12 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { else layerName.setValue(paintedName); }, }); + const layerZField = new NumberField(paintedLayer ? layerZ(paintedLayer) : 0, { + onChange: (z) => { + if (paintedLayer && z !== layerZ(paintedLayer)) + state.execute(new SetTilemapLayerZCommand(asset.id, painted, z)); + }, + }); const focus: Dropdown = new Dropdown(FOCUS.find((f) => f.focus === paint.focus)?.label ?? '', { onClick: () => new Menu( @@ -259,6 +270,7 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { palette: null, layers: [], layerName, + layerZ: layerZField, focus, addLayer: new Button('Add layer', { onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)), @@ -326,6 +338,10 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { const nameRow = labelledRow('Name').row; nameRow.add(layerName, 1); host.body.add(nameRow); + const zRow = labelledRow('Z').row; + zRow.add(layerZField, 1); + host.body.add(zRow); + addNote(host, 'Added to the map’s own Z, as a child’s is.', 'small'); const showRow = labelledRow('Show').row; showRow.add(focus, 1); host.body.add(showRow); @@ -346,7 +362,9 @@ function tileIdsOf(state: EditorState): string[] { */ function tilemapSignature(state: EditorState, asset: TilemapAsset): string { const paint = state.tilemapPaint; - const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(','); + const layers = asset.layers + .map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}:${layerZ(l)}`) + .join(','); const tiles = tileIdsOf(state).join(','); return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${paint.focus}|${layers}|${tiles}`; } diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 4365441b..20f4a100 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -9,12 +9,13 @@ import { } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import { type Extent, scaleOf } from '../../core/model/matrix'; -import { sortByZ, type Vec2, textOf } from '../../core/model/Properties'; +import { type Vec2, textOf } from '../../core/model/Properties'; import { footprintExtent, footprintOf, placementOf, textAnchor } from '../../core/properties/bounds'; import { rectsIntersect } from '../../core/properties/camera'; import type { EditorState } from '../state'; import { type Size, Theme, Widget } from '../../ui'; import { fontLibrary } from '../../viewport/FontLibrary'; +import { drawSlots } from '../../viewport/drawOrder'; import type { DrawItem } from '../../viewport/items'; import { hashId } from '../../core/particles/nodes'; import { ParticleField } from '../../viewport/particles'; @@ -55,7 +56,7 @@ export abstract class WorldViewport extends Widget { private readonly sprites = new Map(); /** The drawn particles of each source, one container – and one draw call – per source. */ private readonly fields = new Map(); - /** The meshes of each placed tilemap, one container per map. */ + /** The meshes of each placed tilemap, one container per layer (#174). */ private readonly tilemaps = new Map(); private visibleCount = 0; private animated = false; @@ -274,25 +275,28 @@ export abstract class WorldViewport extends Widget { } /** - * Re-attaches the object nodes in ascending `z`, ties in hierarchy order. Explicit rather than - * Pixi's lazy `sortableChildren` so the order is deterministic and honours hierarchy changes. + * Re-attaches the object nodes in ascending `z`, ties in hierarchy order, each layer of a map + * sorted in on its own (#174, see `drawSlots`). Explicit rather than Pixi's lazy + * `sortableChildren` so the order is deterministic and honours hierarchy changes. */ private syncDrawOrder(items: DrawItem[]): void { - const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type))); this.objects.removeChildren(); - for (const item of ordered) { + for (const { id, layerId } of drawSlots(items)) { + if (layerId) { + const layer = this.tilemaps.get(id)?.layerContainer(layerId); + if (layer) this.objects.addChild(layer); + continue; + } // Picture and text are siblings because Graphics may not have children: the picture right // under its node, which only holds the hit area, the text right above its box. - const sprite = this.sprites.get(item.id); + const sprite = this.sprites.get(id); if (sprite) this.objects.addChild(sprite); - const node = this.nodes.get(item.id); + const node = this.nodes.get(id); if (node) this.objects.addChild(node); - const text = this.texts.get(item.id); + const text = this.texts.get(id); if (text) this.objects.addChild(text); - const field = this.fields.get(item.id); + const field = this.fields.get(id); if (field) this.objects.addChild(field.container); - const tilemap = this.tilemaps.get(item.id); - if (tilemap) this.objects.addChild(tilemap.container); } } @@ -339,15 +343,15 @@ export abstract class WorldViewport extends Widget { private syncTilemap(item: DrawItem, onScreen: boolean): void { let field = this.tilemaps.get(item.id); if (item.type !== 'tilemap' || !item.tilemap || !onScreen) { - if (field) field.container.visible = false; + if (field) field.visible = false; return; } if (!field) { + // Its layers are attached by `syncDrawOrder`, which runs after every item is synced. field = new TilemapField(); this.tilemaps.set(item.id, field); - this.objects.addChild(field.container); } - field.container.visible = true; + field.visible = true; field.sync(item, textureLibrary, gameClock.now, this.tilemapFocus(item)); if (field.animated) this.animated = true; } @@ -375,7 +379,7 @@ export abstract class WorldViewport extends Widget { tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null { const field = this.tilemaps.get(objectId); const item = this.lastItems.find((i) => i.id === objectId); - if (!field || !field.container.visible || !item?.tilemap) return null; + if (!field?.visible || !item?.tilemap) return null; return { drawCalls: field.drawCalls, tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0), diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts new file mode 100644 index 00000000..163f3d54 --- /dev/null +++ b/src/viewport/drawOrder.ts @@ -0,0 +1,30 @@ +import { isSymbolType } from '../core/model/AssetTypes'; +import { sortByZ } from '../core/model/Properties'; +import type { DrawItem } from './items'; + +/** + * One thing sorted into the draw order: an object, or one layer of a placed tilemap (#174) – + * `layerId` is empty for the object itself. A layer is sorted on its own, so a figure can walk + * between the ground and the treetops of one map. + */ +export interface DrawSlot { + id: string; + layerId: string; + z: number; +} + +/** + * Back to front: ascending `z`, ties in hierarchy order, and a map's layers right after the map in + * their list order. With every layer at 0 that is exactly the order of before, where a map was one + * piece. Symbols are left out, they are drawn above everything. + */ +export function drawSlots(items: readonly DrawItem[]): DrawSlot[] { + const slots: DrawSlot[] = []; + for (const item of items) { + if (isSymbolType(item.type)) continue; + slots.push({ id: item.id, layerId: '', z: item.z }); + for (const layer of item.tilemap?.layers ?? []) + slots.push({ id: item.id, layerId: layer.id, z: item.z + layer.z }); + } + return sortByZ(slots); +} diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 37a49dd5..967f2dcb 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -6,7 +6,7 @@ import type { Mat } from '../core/model/matrix'; import type { PropertyBag } from '../core/model/Properties'; import type { ParticlesAsset } from '../core/model/Asset'; import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile'; -import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap'; +import { isLayerVisible, layerTiles, layerZ, type TileDraw } from '../core/model/tilemap'; import { type CellPolygon, type CellRect, @@ -63,9 +63,13 @@ export interface TilemapTile { fps: number; } -/** One layer, already worked out: which quarters each cell shows and which tile draws it. */ +/** + * One layer, already worked out: which quarters each cell shows and which tile draws it, and how + * far above the map's own `z` it is sorted in among the other objects (#174). + */ export interface TilemapLayerDraw { id: string; + z: number; tiles: TileDraw[]; } @@ -96,7 +100,7 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag): for (const layer of asset.layers) { if (!isLayerVisible(layer)) continue; const tiles = layerTiles(project, props, layer); - layers.push({ id: layer.id, tiles }); + layers.push({ id: layer.id, z: layerZ(layer), tiles }); for (const tile of tiles) { if (used.has(tile.tileId)) continue; const source = findAssetOf(project, tile.tileId, 'tile'); diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts index 28248a79..e5ecb69b 100644 --- a/src/viewport/tilemap.ts +++ b/src/viewport/tilemap.ts @@ -27,16 +27,31 @@ export function layerAlpha(layerId: string, focus: LayerFocus | null): number { return focus.mode === 'dim' ? DIMMED_LAYER_ALPHA : 0; } +/** + * One container per layer rather than one per map: each layer has a `z` of its own (#174) and is + * sorted in among the other objects on its own – where is the viewport's call, not this one's. + */ export class TilemapField { - readonly container = new Container(); + private readonly layers = new Map(); private readonly meshes = new Map(); + private shown = true; /** True after a sync that drew a tile with more than one picture. */ animated = false; /** How many meshes the last sync drew – one draw call each (test hook). */ drawCalls = 0; - constructor() { - this.container.eventMode = 'none'; + get visible(): boolean { + return this.shown; + } + + set visible(shown: boolean) { + this.shown = shown; + for (const layer of this.layers.values()) layer.visible = shown; + } + + /** The container a layer draws into; null for a layer the last sync did not draw. */ + layerContainer(layerId: string): Container | null { + return this.layers.get(layerId) ?? null; } /** @@ -46,14 +61,17 @@ export class TilemapField { sync(item: DrawItem, textures: TextureLibrary, now: number, focus: LayerFocus | null = null): void { const draw = item.tilemap; this.animated = false; - // The whole map sits under one node, so the object's own transform is applied once rather - // than baked into every vertex. - placeNode(this.container, item.m); const tile = tilemapTileSize(item.props); const origin = cellOrigin(item.props, 0, 0); - const drawn: Mesh[] = []; + const drawn: { layer: Container; mesh: Mesh }[] = []; const alive = new Set(); + const aliveLayers = new Set(); for (const layer of draw?.layers ?? []) { + aliveLayers.add(layer.id); + // Each layer sits under a node of its own carrying the map's transform, so that is applied + // once per layer rather than baked into every vertex. + const container = this.layerFor(item.id, layer.id); + placeNode(container, item.m); const alpha = layerAlpha(layer.id, focus); if (alpha === 0) continue; for (const [tileId, tiles] of groupByTile(layer.tiles)) { @@ -69,7 +87,7 @@ export class TilemapField { // Per mesh rather than per layer: the meshes of one layer never overlap, so fading each // one looks the same as fading the layer as a whole. mesh.alpha = alpha; - drawn.push(mesh); + drawn.push({ layer: container, mesh }); } } for (const [key, mesh] of this.meshes) { @@ -77,13 +95,30 @@ export class TilemapField { mesh.destroy(); this.meshes.delete(key); } - // Re-attached in order rather than sorted lazily: the layer order is the drawing order, and - // the list is at most one entry per tile and layer. - this.container.removeChildren(); - for (const mesh of drawn) this.container.addChild(mesh); + for (const [id, layer] of this.layers) { + if (aliveLayers.has(id)) continue; + layer.destroy(); + this.layers.delete(id); + } + // Re-attached rather than kept: a mesh whose layer showed nothing last time has to come back. + for (const layer of this.layers.values()) layer.removeChildren(); + for (const { layer, mesh } of drawn) layer.addChild(mesh); this.drawCalls = drawn.length; } + private layerFor(objectId: string, layerId: string): Container { + let layer = this.layers.get(layerId); + if (!layer) { + layer = new Container(); + layer.eventMode = 'none'; + // The draw order reports it by this, next to the object's own id. + layer.label = `${objectId}#${layerId}`; + layer.visible = this.shown; + this.layers.set(layerId, layer); + } + return layer; + } + private meshFor(key: string, geometry: MeshGeometry, texture: Texture): Mesh { const held = this.meshes.get(key); if (held) { @@ -99,7 +134,8 @@ export class TilemapField { } destroy(): void { - this.container.destroy({ children: true }); + for (const layer of this.layers.values()) layer.destroy({ children: true }); + this.layers.clear(); this.meshes.clear(); } } diff --git a/tests/viewport/layer-z.test.ts b/tests/viewport/layer-z.test.ts new file mode 100644 index 00000000..75415767 --- /dev/null +++ b/tests/viewport/layer-z.test.ts @@ -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; + +/** 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']); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index c62930de..9661b880 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -452,6 +452,39 @@ export async function tilemap(t) { JSON.stringify({ layers, renamedRow, nameNow }), ); + // 40p. The Z field below the name gives the painted layer a z of its own, added to the map's + // (#174): at −5 the upper layer is sorted in below the map itself and below the lower layer. + const zField = await inPanel(t, '__gm.tilemap.layerZAt()'); + if (zField) { + await click(zField.x, zField.y); + await press('a', { ctrl: true }); + await typeText('-5'); + await press('Enter'); + } + await settle(); + await shot('40-tilemap-layer-z'); + const withZ = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); + const slot = (order, id) => order.indexOf(id); + const orderZ = await json('__gm.drawOrder()'); + const upperSlot = `${MAP_OBJECT}#${withZ[1].id}`; + const lowerSlot = `${MAP_OBJECT}#${withZ[0].id}`; + await undo(); + await settle(); + const orderBack = await json('__gm.drawOrder()'); + const zBack = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[1].z; + check( + 'the Z field sorts the painted layer below the map and the other layer, and undo puts it back', + withZ[1].z === -5 && + withZ[0].z === 0 && + slot(orderZ, upperSlot) >= 0 && + slot(orderZ, upperSlot) < slot(orderZ, MAP_OBJECT) && + slot(orderZ, upperSlot) < slot(orderZ, lowerSlot) && + zBack === 0 && + slot(orderBack, MAP_OBJECT) < slot(orderBack, lowerSlot) && + slot(orderBack, lowerSlot) < slot(orderBack, upperSlot), + JSON.stringify({ withZ, orderZ, orderBack, zBack }), + ); + // Pixels again, so the animation is cut down to one step for the reading, as in 40e. await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']"); await settle();