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

+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),