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)
game-maker / gate (push) Successful in 1m58s
game-maker / suite (push) Successful in 6m50s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 14:03:20 +02:00
1 parent 5385d9e152
commit 47eea2f6e4
13 files changed
+389 -38

No files matched your search

+1 -1
View File
@@ -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
+28
View File
@@ -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;
+4 -1
View File
@@ -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<string, string>;
}
+5
View File
@@ -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);
}
+1
View File
@@ -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;
+7 -1
View File
@@ -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;
+21 -3
View File
@@ -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}`;
}
+20 -16
View File
@@ -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<string, Mesh>();
/** The drawn particles of each source, one container – and one draw call – per source. */
private readonly fields = new Map<string, ParticleField>();
/** 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<string, TilemapField>();
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),
+30
View File
@@ -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);
}
+7 -3
View File
@@ -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');
+49 -13
View File
@@ -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<string, Container>();
private readonly meshes = new Map<string, Mesh>();
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<string>();
const aliveLayers = new Set<string>();
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();
}
}
+183
View File
@@ -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']);
});
});
+33
View File
@@ -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();