All examples
Tilemap layers
The village from the Tilemap example with its ground, walls and canopy moved into one sorting layer and listed front to back in the file, so orderInLayer alone sets the order: ground at -1, walls at 0, roofs and tree crowns at 1. A villager sprite in the same sorting layer walks a loop in front of the walls and under the roofs, because on a tie tiles draw behind sprites. A script rewrites grass cells several times a second, rebuilding chunks, and the layers stay in order.
Off airLivePaused
Tilemap layers
2D · 2D

WebGPU: checking…See browser support
Try this
- Follow the villager up past the house: she walks over its walls, then vanishes under its roof.
- Drag Rewrites to 30 a second and watch the fences and walls: they stay on top of the grass however often their chunks rebuild.
- Turn off Cull chunks and watch Tiles drawn climb to the whole map.
Show source code
Source
import { Camera2D, SpriteAnimator, SpriteRenderer, Tilemap, TilemapRenderer, twoD, Vec2 } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { Gardener, LOOP, Walker } from "./scripts.ts";import type { App, SpriteAnimationAsset, SpriteAtlasAsset, TilemapAsset, TilemapLayerDefinition } from "ignifx";/** * Tilemap layers and sprites in **one** sorting layer, ordered by `orderInLayer`. * * `village-layers.tilemap.json` is the `tilemap` example's village with its three tile layers moved * into the `Default` sorting layer, which is not Y-sorted, and listed front to back — the opposite * of how they draw — so what you see is `orderInLayer` deciding: * * - `Ground`, `orderInLayer` -1: grass, roads and water, at the back. * - `Solid`, `orderInLayer` 0: fences, walls and trunks. It ties with the villager, a * `SpriteRenderer` in the same sorting layer, and on a tie tiles draw behind sprites, so she walks * in front of a house's walls. * - `Canopy`, `orderInLayer` 1: roofs and tree crowns, in front of her — she disappears under a * roof and a tree on her loop. * * Two layers with the same `orderInLayer` draw in their order in the file. With Y-sort on for the * sorting layer, tiles share the sprites' layer instead, and `orderInLayer` is their Y-sort bias. * * The gardener rewrites a few grass cells several times a second with `Tilemap.setCells`, and each * write rebuilds the chunks it touches (chunks are four cells here, so there are many). Before * ignifx 0.18.0 a rebuild could put a chunk's `Ground` tiles in front of its `Solid` ones — grass * over fences — and `orderInLayer` was ignored; now the order holds however often chunks rebuild. *//** The design resolution the pixel-perfect camera fits a whole-number zoom to: 20 by 11 cells. */const REFERENCE_RESOLUTION = { x: 320, y: 180 } as const;/** Where the camera looks: the middle of the villager's loop. */const VIEW_CENTRE = { x: 21.5, y: 9.6 } as const;/** Cells per chunk: four, so a batch of writes rebuilds several chunks. */const CHUNK_SIZE = 4;/** * @param app - The running app. * @returns How many sprites the sprite layers hold, tiles included. */function tilesDrawn(app: App): number { let total = 0; for (const layer of app.twoD.layers) { total += layer.count; } return total;}/** * One layer's place in the draw order, in words. * * @param layer - The layer as the map defines it. * @param index - Its index in the file. * @returns E.g. `"order 1 · file #1 · over sprites"`. */function describeLayer(layer: TilemapLayerDefinition, index: number): string { const place = layer.orderInLayer > 0 ? "over sprites" : "under sprites"; return `order ${String(layer.orderInLayer)} · file #${String(index + 1)} · ${place}`;}bootExample({ title: "Tilemap layers", extensions: [ // One world metre is one 16-pixel tile. No `ySort`: `Default` draws by order, not by height. twoD({ pixelsPerUnit: 16 }), ], settings: { // Multisampling would soften exactly the edges pixel art exists to keep sharp. rendering: { msaaSamples: 1 }, }, cursor: "default", async setup({ app, panel, random }) { app.registerComponents([Walker, Gardener]); const [mapAsset, tiles, atlas, clips] = await Promise.all([ app.assets.loadAsync<TilemapAsset>("2d/village-layers.tilemap.json"), app.assets.loadAsync<SpriteAtlasAsset>("2d/tiny-town.atlas.json"), app.assets.loadAsync<SpriteAtlasAsset>("2d/villager.atlas.json"), app.assets.loadAsync<SpriteAnimationAsset>("2d/villager.spriteanim.json"), ]); const definition = mapAsset.value.definition; const level = app.world.createEntity("Village"); const map = level.addComponent(Tilemap, { map: mapAsset, chunkSize: CHUNK_SIZE }); const renderer = level.addComponent(TilemapRenderer, { atlas: tiles, cullChunks: true }); const gardener = level.addComponent(Gardener); gardener.layer = definition.layers.findIndex((layer) => layer.name === "Ground"); gardener.random = random; const start = LOOP[0] ?? { x: 0, y: 0 }; const villager = app.world.createEntity("Villager"); villager.transform.position2D = new Vec2(start.x, start.y); // The same sorting layer as every tile layer, at the default `orderInLayer`. villager.addComponent(SpriteRenderer, { sprite: atlas, sortingLayer: "Default" }); villager.addComponent(SpriteAnimator, { animations: clips, defaultClip: "walk_right", playOnAwake: true }); const walker = villager.addComponent(Walker); const eye = app.world.createEntity("Main Camera"); eye.transform.position2D = new Vec2(VIEW_CENTRE.x, VIEW_CENTRE.y); eye.addComponent(Camera2D, { pixelPerfect: true, referenceResolution: REFERENCE_RESOLUTION }); panel({ title: "Tilemap layers", groups: [ { label: "Draw order, back to front", controls: definition.layers .map((layer, index) => ({ layer, index })) .toSorted((a, b) => a.layer.orderInLayer - b.layer.orderInLayer || a.index - b.index) .map(({ layer, index }) => readout(layer.name, (): string => describeLayer(layer, index))), }, { label: "Chunk rebuilds", controls: [ slider( "Rewrites", { min: 0, max: 30, step: 1, format: (value: number): string => `${String(value)} / s` }, bind(gardener, "rate"), ), readout("Cells rewritten", (): string => String(gardener.rewritten)), toggle("Cull chunks", bind(renderer, "cullChunks")), readout("Chunk size", (): string => `${String(map.chunkSize)} cells`), readout("Tiles drawn", (): string => String(tilesDrawn(app))), ], }, { label: "Villager", controls: [ slider( "Speed", { min: 0, max: 6, step: 0.5, format: (value: number): string => `${value.toFixed(1)} m/s` }, bind(walker, "speed"), ), ], }, ], }); },});import { f32, Script, SpriteAnimator, Tilemap, Vec2 } from "ignifx";import type { ScriptCallbacks, TileWrite } from "ignifx";/** * The two scripts `tilemap-layers` runs: a villager who walks a loop on her own, and a gardener who * keeps rewriting grass cells, so the map's chunks are rebuilt all the time. *//** A point on the villager's loop, in world metres (one metre is one cell). */interface Waypoint { readonly x: number; readonly y: number;}/** * The loop: along the road, up past a house's walls and under its roof, across under a tree, and * back down. Her origin is her feet. */export const LOOP: readonly Waypoint[] = Object.freeze([ { x: 16.5, y: 6.3 }, { x: 26.5, y: 6.3 }, { x: 26.5, y: 12.4 }, { x: 16.5, y: 12.4 },]);/** Walks its entity round {@link LOOP} and keeps the walk clip facing the way it goes. */export class Walker extends Script.define({ speed: f32(2.5, { min: 0, tooltip: "Metres per second." }) }) implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "tilemap-layers/Walker"; /** The waypoint she is heading for. */ #target = 1; /** The clip playing now. */ #clip = ""; /** * Steps along the loop. `dt` is scaled, so `?static=1` holds her still. * * @param dt - Seconds since the previous frame. */ update(dt: number): void { const target = LOOP[this.#target] ?? LOOP[0]; if (target === undefined) { return; } const at = this.transform.position2D; const dx = target.x - at.x; const dy = target.y - at.y; const distance = Math.hypot(dx, dy); const step = this.speed * dt; if (distance <= step) { this.transform.position2D = new Vec2(target.x, target.y); this.#target = (this.#target + 1) % LOOP.length; } else if (distance > 0) { this.transform.position2D = new Vec2(at.x + (dx / distance) * step, at.y + (dy / distance) * step); } const facing = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "up" : "down"; const clip = this.speed > 0 ? `walk_${facing}` : `idle_${facing}`; if (clip !== this.#clip) { this.#clip = clip; this.entity.getComponent(SpriteAnimator)?.play(clip); } }}/** The two grass tiles of the Tiny Town set (global ids), which the gardener swaps. */const GRASS = [1, 2] as const;/** The cells the gardener works: the part of the village the camera shows. */export const GARDEN = { minX: 11, maxX: 31, minY: 4, maxY: 15 } as const;/** * Swaps a few grass cells of one layer every so often with `Tilemap.setCells`. Each batch rebuilds * the chunks it touches, which is what reordered a tilemap's layers before ignifx 0.18.0. */export class Gardener extends Script.define({ rate: f32(6, { min: 0, tooltip: "Batches of writes per second." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "tilemap-layers/Gardener"; /** The layer it rewrites: the index of `Ground` in the document. */ layer = 0; /** The page's seeded generator; examples never call `Math.random`. */ random: () => number = () => 0.5; /** How many writes changed a cell so far. */ rewritten = 0; /** Seconds until the next batch. */ #wait = 0; /** * Writes a batch when it is due. * * @param dt - Seconds since the previous frame; scaled, so `?static=1` writes nothing. */ update(dt: number): void { if (this.rate <= 0) { return; } this.#wait -= dt; if (this.#wait > 0) { return; } this.#wait += 1 / this.rate; if (this.#wait < 0) { this.#wait = 0; } const map = this.entity.getComponent(Tilemap); if (map === null || map.definition === null) { return; } const writes: TileWrite[] = []; // Four cells a batch, anywhere in view, so several chunks are rebuilt at once. for (let attempt = 0; attempt < 16 && writes.length < 4; attempt += 1) { const x = GARDEN.minX + Math.floor(this.random() * (GARDEN.maxX - GARDEN.minX + 1)); const y = GARDEN.minY + Math.floor(this.random() * (GARDEN.maxY - GARDEN.minY + 1)); const tile = map.getTile(this.layer, x, y); if (tile === GRASS[0] || tile === GRASS[1]) { writes.push({ layer: this.layer, x, y, tileId: tile === GRASS[0] ? GRASS[1] : GRASS[0] }); } } if (writes.length > 0) { this.rewritten += map.setCells(writes); } }}// Writes `examples/assets/2d/village-layers.tilemap.json`, the map `tilemap-layers` draws. Run it// from the repository root://// node examples/tilemap-layers/tools/build-layered-map.ts//// It is `2d/village.tilemap.json` (the `tilemap` example's village) with its three tile layers// moved into **one** sorting layer, `Default`, and their draw order given by `orderInLayer` alone://// | Layer | `orderInLayer` | Place in the file |// | -------- | -------------- | ----------------- |// | `Canopy` | 1 | first |// | `Solid` | 0 | second |// | `Ground` | -1 | third |//// The file lists them front to back, the opposite of the order they draw in, so the page shows// `orderInLayer` deciding, not the file order. From ignifx 0.18.0 a tilemap layer's `orderInLayer`// applies on a sorting layer without Y-sort, and a chunk rebuild no longer reorders the layers.// Every tile, tileset and object is unchanged; the runs of each layer are re-encoded as they were.import { readFile, writeFile } from "node:fs/promises";import path from "node:path";import { decodeTileRle, defineTilemap, encodeTileRle } from "ignifx";import type { TilemapLayerInput, TileRleData } from "ignifx";/** The `examples/assets/2d` directory. */const DIR = path.resolve(import.meta.dirname, "..", "..", "assets", "2d");/** The new order, front to back, with each layer's `orderInLayer`. */const LAYERS = [ { name: "Canopy", orderInLayer: 1 }, { name: "Solid", orderInLayer: 0 }, { name: "Ground", orderInLayer: -1 },] as const;/** * Rewraps every array of numbers in pretty-printed JSON to rows of at most 120 columns, the way * `village.tilemap.json` is laid out, instead of one number per line. * * @param json - `JSON.stringify(value, null, 2)` output. * @returns The same JSON, with packed number arrays. */function packNumberArrays(json: string): string { return json.replaceAll(/\[\n(\s*)(-?\d[\d\s,.-]*)\n(\s*)\]/gu, (_match, indent: string, body: string, close: string) => { const numbers = body.split(/,\s*/u).map((item) => item.trim()); const rows: string[] = []; let row = ""; for (const item of numbers) { const next = row === "" ? item : `${row}, ${item}`; if (indent.length + next.length + 1 > 120 && row !== "") { rows.push(`${row},`); row = item; } else { row = next; } } rows.push(row); return `[\n${rows.map((line) => indent + line).join("\n")}\n${close}]`; });}/** One layer of the source document, as it is stored. */interface StoredLayer extends Omit<TilemapLayerInput, "tiles"> { readonly tiles: readonly number[] | TileRleData;}const source = JSON.parse(await readFile(path.join(DIR, "village.tilemap.json"), "utf8")) as { readonly layers: readonly StoredLayer[]; readonly [key: string]: unknown;};const layers = LAYERS.map(({ name, orderInLayer }) => { const layer = source.layers.find((candidate) => candidate.name === name); if (layer === undefined) { throw new Error(`village.tilemap.json has no layer named ${name}.`); } const dense = Array.isArray(layer.tiles) ? layer.tiles : decodeTileRle((layer.tiles as TileRleData).rle); return { ...layer, sortingLayer: "Default", orderInLayer, tiles: { rle: encodeTileRle(dense) } };});const document = { ...source, layers };// Parse it the way the loader will, so a mistake here fails now rather than in the page.defineTilemap(document as unknown as Parameters<typeof defineTilemap>[0]);const out = path.join(DIR, "village-layers.tilemap.json");await writeFile(out, `${packNumberArrays(JSON.stringify(document, null, 2))}\n`);console.log(`${path.relative(process.cwd(), out)}: ${layers.map((layer) => `${layer.name} ${String(layer.orderInLayer)}`).join(", ")}`);