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

+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();
}
}