All examples

Tilemap

2D2D
  • Keyboard
  • Gamepad
  • Touch

Walk through a village loaded from a Tiled map. Walls, fences and trees block movement, while roofs and treetops appear in front of the character. The camera follows the villager, and the renderer skips map sections outside the view.

Off airLivePaused

Tilemap

2D · 2D

A pixel-art village seen from above: a wide dirt lane crossing the frame with a spur running north, red and grey cottages with tiled roofs along it, autumn and pine trees, mushrooms and a fenced paddock, and a small villager in a red tunic standing on the lane.
Open standalone

WebGPU: checking…

Try this

  1. Turn off Cull chunks and watch the tile count double: the whole map is materialised instead of the part you can see.
  2. Walk into the fence, the tree trunks and the house fronts; every one of them is a collider the map file described.
  3. Follow a lane to the edge of the village: the camera stops at the map bounds rather than showing you the void.
Read the guide
Show source code
main.ts
import {  Camera2D,  Camera2DFollow,  physics2d,  spawnTilemapObjects,  Tilemap,  TilemapCollider2D,  TilemapRenderer,  twoD,  Vec2,  VirtualJoystick,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { spawnVillager, Villager, WALK_ACTIONS } from "./villager.ts";import type { App, AssetHandle, Entity, MutableVec2, SpriteAnimationAsset, SpriteAtlasAsset } from "ignifx";import type { TileObjectContext, TilemapAsset } from "ignifx";/** * A hand-drawn Tiled map with three tile layers, chunk culling, and a villager who walks it with * the camera following. * * Three components share the work. `Tilemap` owns the document — the grid, the tilesets and the * per-tile colliders; `TilemapRenderer` draws it from one atlas, a chunk at a time, dropping the * chunks the camera cannot see; `TilemapCollider2D` turns the map's merged outlines into one static * Rapier body. `village.tmj.json` beside this file is the Tiled export the map came from, and * `tools/build-2d-assets.ts` is what ran it through `importTiledMap` at build time. * * The three layers are the lesson worth watching. `Ground` is grass and stone; `Solid` is the * fences, the walls and the tree roots, and it is the only layer with collision; `Canopy` is the * roofs and the crowns, and it draws in a sorting layer **above** the villager — which is why * walking into a house or under a tree hides her. *//** The map is forty by twenty cells of one metre, and the camera may not leave it. */const LEVEL_SIZE = { x: 40, y: 20 } as const;/** The design resolution the pixel-perfect camera fits a whole-number zoom to. */const REFERENCE_RESOLUTION = { x: 320, y: 180 } as const;/** Cells per chunk: eight, not the default 32, so culling has more than one chunk to drop. */const CHUNK_SIZE = 8;/** Scratch for the panel's cell readout, so reading it four times a second allocates nothing. */const cellScratch: MutableVec2 = new Vec2();/** * @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;}/** * Loads the four documents the level is built from. * * @remarks * Awaited before `app.start()`, where a completed load settles at once; started after it, a load is * delivered in a later frame's `PreUpdate` and the first frames would draw an empty map. * * @param app - The app being set up. * @returns The map, the tile atlas, and the villager's atlas and clips. */async function loadLevel(app: App): Promise<{  readonly map: AssetHandle<TilemapAsset>;  readonly tiles: AssetHandle<SpriteAtlasAsset>;  readonly atlas: AssetHandle<SpriteAtlasAsset>;  readonly clips: AssetHandle<SpriteAnimationAsset>;}> {  const [map, tiles, atlas, clips] = await Promise.all([    app.assets.loadAsync<TilemapAsset>("2d/village.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"),  ]);  return { map, tiles, atlas, clips };}bootExample({  title: "Tilemap",  extensions: [    // One world metre is one 16-pixel tile. The default is 100, which would draw every sprite here    // at a sixth of its intended size.    twoD({ pixelsPerUnit: 16, ySort: { Default: true } }),    physics2d(),  ],  settings: {    rendering: {      // Multisampling is off because it would soften exactly the edges pixel art exists to keep      // sharp. There is no clear colour to set: the camera stays inside the map, so none shows.      msaaSamples: 1,    },    time: { fixedDeltaTime: 1 / 60 },    // Back to front. `Canopy` is above `Default`, which is what puts a roof in front of a villager.    sortingLayers: { sortingLayers: ["Ground", "Terrain", "Default", "Canopy"] },    layers: { layers: ["Default", "Player", "Terrain"] },    // A top-down world has no gravity: the controller goes exactly where `move` says.    physics2d: { gravity: { x: 0, y: 0 }, defaultMaterial: { friction: 0, restitution: 0 } },  },  async setup({ app, panel }) {    app.registerComponents([Villager]);    app.input.loadActions(WALK_ACTIONS);    const assets = await loadLevel(app);    // The map's objects layer is a list of intentions — "someone starts here" — and only the game    // knows what each one becomes. Registered before the map is walked, or nothing spawns.    app.twoD.registerTileObjectFactory("spawn", (context: TileObjectContext): Entity =>      spawnVillager(app, context, assets.atlas, assets.clips),    );    const level = app.world.createEntity("Level");    level.layer = app.world.layers.requireIndex("Terrain");    const map = level.addComponent(Tilemap, { map: assets.map, chunkSize: CHUNK_SIZE });    const renderer = level.addComponent(TilemapRenderer, { atlas: assets.tiles, cullChunks: true });    // Adjacent solid cells are merged into as few polygons as the tiles allow, so the fence around    // the paddock is one shape rather than one per cell.    level.addComponent(TilemapCollider2D).collisionData = map.collisionData;    // The call answers with what it created, in document order, which is how the camera finds its    // subject without the factory having to publish it.    const villager = spawnTilemapObjects(app, app.twoD, map)[0];    if (villager === undefined) {      throw new Error('village.tilemap.json has no object of type "spawn".');    }    const eye = app.world.createEntity("Main Camera");    eye.transform.position2D = new Vec2(villager.transform.position2D.x, villager.transform.position2D.y);    eye.addComponent(Camera2D, {      // `orthographicSize` is ignored while `pixelPerfect` is on: the zoom is      // `viewportHeight / referenceResolution.y`, snapped to a whole number, so one texel always      // covers an exact square of screen pixels.      pixelPerfect: true,      referenceResolution: REFERENCE_RESOLUTION,      follow: villager,      followDamping: 0.12,      followOffset: { x: 0, y: 0.5 },      deadZone: { x: 1.5, y: 1 },      boundsMin: { x: 0, y: 0 },      boundsMax: LEVEL_SIZE,    });    eye.addComponent(Camera2DFollow);    // On-screen controls only where there is a touch screen: on a desktop they would cover the map.    if (navigator.maxTouchPoints > 0) {      const joystick = new VirtualJoystick(app, {        control: "joystick",        ariaLabel: "Walk",        style: { left: "1.5rem", bottom: "calc(1.5rem + var(--ignifx-safe-bottom, 0px))" },      });      window.addEventListener("pagehide", (): void => {        joystick.dispose();      });    }    panel({      title: "Tilemap",      groups: [        {          label: "Map",          controls: [            toggle("Cull chunks", bind(renderer, "cullChunks")),            // Every tile the renderer has materialised, summed over the sprite layers. Turn the            // toggle off and it climbs to the whole map; turn it on and it falls back to the            // chunks the camera can see. `app.twoD.spriteCount` is the other figure and counts            // only `SpriteRenderer` components — one, here, the villager.            readout("Tiles drawn", (): string => String(tilesDrawn(app))),            readout("Chunk size", (): string => `${String(map.chunkSize)} cells`),          ],        },        {          label: "Villager",          controls: [            slider(              "Speed",              { min: 1, max: 9, step: 0.5, format: (value: number): string => `${value.toFixed(1)} m/s` },              bind(villager.requireComponent(Villager), "speed"),            ),            readout("Cell", (): string => {              const cell = map.worldToCell(villager.transform.position2D, cellScratch);              return `${String(cell.x)}, ${String(cell.y)}`;            }),          ],        },      ],    });  },});
villager.ts
import { CharacterController2D, defineInputActions, f32, Script, SpriteAnimator, SpriteRenderer, Vec2 } from "ignifx";import type {  App,  AssetHandle,  Entity,  InputAction,  MutableVec2,  ScriptCallbacks,  SpriteAnimationAsset,  SpriteAtlasAsset,  TileObjectContext,} from "ignifx";/** * The villager: the walk, the facing and the clip, in one file, so `main.ts` is only the map she * walks on. *//** The four facings the sheet has, in the order a stick angle is bucketed into. */const FACINGS = ["right", "up", "left", "down"] as const;/** The clip for each facing, standing and walking, named once so the frame builds no strings. */const IDLE_CLIPS = FACINGS.map((facing) => `idle_${facing}`);const WALK_CLIPS = FACINGS.map((facing) => `walk_${facing}`);/** The facing she starts in: down, towards the camera. */const START_FACING = 3;/** The actions the villager reads. Its own map, so the kit's camera actions are untouched. */export const WALK_ACTIONS = defineInputActions({  maps: [    {      name: "Village",      actions: [        {          name: "walk",          type: "vector2",          bindings: [            {              composite: "2DVector",              up: "<Keyboard>/w",              down: "<Keyboard>/s",              left: "<Keyboard>/a",              right: "<Keyboard>/d",            },            {              composite: "2DVector",              up: "<Keyboard>/arrowUp",              down: "<Keyboard>/arrowDown",              left: "<Keyboard>/arrowLeft",              right: "<Keyboard>/arrowRight",            },            { path: "<Gamepad>/leftStick", processors: ["deadzone(0.2)"] },            { path: "<Gamepad>/dpad" },            // The on-screen stick applies its own dead zone.            { path: "<Virtual>/joystick" },          ],        },      ],    },  ],});/** * Walks its entity on the tilemap's collision, and keeps the animator on the clip that matches * where it is heading. * * Input is sampled in `update` and spent in `fixedUpdate`, because those are two different clocks: * a frame carries zero, one or two fixed steps, so reading a stick inside the step would sample the * same frame twice or miss it entirely. */export class Villager extends Script.define({ speed: f32(4.5) }) implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "tilemap/Villager";  #controller: CharacterController2D | null = null;  #animator: SpriteAnimator | null = null;  #walk: InputAction | null = null;  /** This frame's direction, already normalised so a diagonal is not 1.41 times faster. */  readonly #wish: MutableVec2 = new Vec2();  /** The displacement handed to the controller. Reused, so the fixed step allocates nothing. */  readonly #step: MutableVec2 = new Vec2();  /** Which way she faces, as an index into {@link FACINGS}, and the clip that is playing. */  #facing = START_FACING;  #clip = "";  awake(): void {    this.#controller = this.entity.requireComponent(CharacterController2D);    this.#animator = this.entity.getComponent(SpriteAnimator);    this.#walk = this.app.input.actions.find("walk");  }  update(): void {    const vector = this.#walk?.vector ?? null;    const length = vector === null ? 0 : Math.hypot(vector.x, vector.y);    if (vector === null || length < 0.01) {      this.#wish.set(0, 0);    } else {      const scale = length > 1 ? 1 / length : 1;      this.#wish.set(vector.x * scale, vector.y * scale);      // A stick points anywhere; the sheet has four directions, so the angle is bucketed into the      // nearest cardinal — which is what almost every top-down game with this kind of sheet does.      const quadrant = Math.round(Math.atan2(this.#wish.y, this.#wish.x) / (Math.PI / 2));      this.#facing = ((quadrant % 4) + 4) % 4;    }    const clip = (length < 0.01 ? IDLE_CLIPS : WALK_CLIPS)[this.#facing] ?? "idle_down";    if (clip !== this.#clip) {      this.#clip = clip;      this.#animator?.play(clip);    }  }  fixedUpdate(dt: number): void {    this.#step.set(this.#wish.x * this.speed * dt, this.#wish.y * this.speed * dt);    this.#controller?.move(this.#step);  }}/** * Builds the villager for the map's `spawn` object: the tile-object factory `main.ts` registers. * * @param app - The running app. * @param context - The object as the map describes it: its name, cell and size. * @param atlas - The villager's sprite sheet. * @param clips - The villager's clips. * @returns The new entity. */export function spawnVillager(  app: App,  context: TileObjectContext,  atlas: AssetHandle<SpriteAtlasAsset>,  clips: AssetHandle<SpriteAnimationAsset>,): Entity {  const entity = app.world.createEntity(context.name);  entity.layer = app.world.layers.requireIndex("Player");  // The object is a one-metre cell and its position is the cell's bottom-left corner; the sheet's  // frames pivot at `[0.5, 1]`, so the entity's origin is under her feet.  entity.transform.position2D = new Vec2(context.position.x + context.size.x / 2, context.position.y);  entity.addComponent(SpriteRenderer, { sprite: atlas, sortingLayer: "Default" });  entity.addComponent(SpriteAnimator, { animations: clips, defaultClip: "idle_down", playOnAwake: true });  // A box, not the default capsule: a top-down character slides along a wall more predictably with  // square corners. The offset lifts the box off the origin, which is at her feet.  entity.addComponent(CharacterController2D, {    shape: "box",    radius: 0.3,    height: 0.5,    offset: { x: 0, y: 0.25 },    slopeLimit: 90,    snapToGround: 0,  });  entity.addComponent(Villager);  return entity;}
village.tmj.json
{  "compressionlevel": -1,  "infinite": false,  "orientation": "orthogonal",  "renderorder": "right-down",  "tiledversion": "1.11.2",  "type": "map",  "version": "1.10",  "tilewidth": 16,  "tileheight": 16,  "width": 40,  "height": 20,  "nextlayerid": 5,  "nextobjectid": 2,  "properties": [    {      "name": "title",      "type": "string",      "value": "Willowbrook"    }  ],  "tilesets": [    {      "name": "town",      "firstgid": 1,      "image": "tiny-town.png",      "imagewidth": 203,      "imageheight": 135,      "tilewidth": 16,      "tileheight": 16,      "spacing": 1,      "margin": 0,      "columns": 12,      "tilecount": 96,      "tiles": [        {          "id": 27,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 28,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 44,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 45,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 46,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 56,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 57,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 58,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 68,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 69,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 70,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 73,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 77,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 80,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 81,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 82,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 84,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 86,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 88,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 90,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        }      ]    }  ],  "layers": [    {      "id": 1,      "name": "Ground",      "type": "tilelayer",      "visible": true,      "opacity": 1,      "x": 0,      "y": 0,      "width": 40,      "height": 20,      "properties": [        {          "name": "sortingLayer",          "type": "string",          "value": "Ground"        }      ],      "data": [        2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1,        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1,        2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,        1, 1, 2, 1, 1, 1, 1, 1, 2, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2,        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1,        1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 13, 15, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1,        1, 1, 1, 1, 1, 1, 13, 15, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 25, 27, 2, 1, 1, 1, 1, 1, 2,        1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 25, 27, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 25, 27, 1, 1, 1,        1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 25, 27, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 25,        27, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 25, 27, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,        1, 1, 1, 25, 27, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 25, 27, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1,        1, 1, 1, 1, 1, 1, 1, 25, 27, 1, 2, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 25, 27, 1, 1, 1, 2, 1, 1, 1, 1,        1, 2, 2, 13, 14, 14, 14, 14, 14, 14, 14, 41, 42, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14,        14, 41, 40, 14, 14, 14, 14, 14, 14, 14, 14, 15, 1, 1, 25, 41, 40, 41, 40, 41, 40, 41, 40, 43, 42, 43, 42, 43,        42, 43, 42, 43, 42, 43, 41, 40, 41, 40, 41, 40, 41, 40, 41, 40, 41, 42, 43, 42, 43, 42, 43, 27, 1, 1, 37, 38,        38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 40, 41, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38,        38, 38, 38, 38, 38, 38, 38, 39, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2, 25, 27, 1, 1, 1, 1, 1,        1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 25, 27, 1, 1,        2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 13, 42,        43, 14, 14, 15, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1,        2, 1, 25, 41, 40, 41, 43, 27, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,        2, 1, 1, 1, 1, 1, 2, 37, 38, 38, 38, 38, 39, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2, 2, 1, 2, 1, 2,        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1      ]    },    {      "id": 2,      "name": "Solid",      "type": "tilelayer",      "visible": true,      "opacity": 1,      "x": 0,      "y": 0,      "width": 40,      "height": 20,      "properties": [        {          "name": "sortingLayer",          "type": "string",          "value": "Terrain"        },        {          "name": "collision",          "type": "bool",          "value": true        }      ],      "data": [        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 28,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 30, 0, 0,        0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 28,        0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 81, 82, 83, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 89, 78, 91, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 89, 91, 78, 0, 0, 0, 0, 0, 0, 0, 0,        30, 0, 85, 87, 74, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 85, 87, 74, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 0, 0, 30, 0, 0, 0, 0, 58, 45, 46, 46, 46, 0, 0, 46,        46, 46, 47, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 57, 6, 0,        0, 0, 0, 0, 0, 0, 59, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 0, 6, 28, 0, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 0, 0, 91,        78, 78, 57, 0, 0, 0, 0, 0, 0, 0, 0, 59, 0, 0, 0, 0, 0, 0, 0, 85, 74, 87, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 57, 0, 0, 0, 0, 0, 0, 0, 6, 59, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 28, 0, 0, 0, 0, 0, 0, 69, 70, 70, 70, 70, 70, 70, 70, 70, 71, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0      ]    },    {      "id": 3,      "name": "Canopy",      "type": "tilelayer",      "visible": true,      "opacity": 1,      "x": 0,      "y": 0,      "width": 40,      "height": 20,      "properties": [        {          "name": "sortingLayer",          "type": "string",          "value": "Canopy"        }      ],      "data": [        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0,        0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 49,        50, 51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 50, 51, 0, 0, 0, 0, 0, 0, 0, 17, 0, 0, 53, 54, 55, 0,        0, 0, 0, 0, 0, 61, 62, 63, 0, 0, 0, 0, 0, 0, 0, 53, 54, 55, 0, 0, 0, 0, 0, 61, 62, 63, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 65, 66, 67, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 66, 67, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 50, 51, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 53, 54, 55, 0, 0, 16, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 61, 62, 63,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 66, 67, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0      ]    },    {      "id": 4,      "name": "Objects",      "type": "objectgroup",      "draworder": "topdown",      "visible": true,      "opacity": 1,      "x": 0,      "y": 0,      "objects": [        {          "id": 1,          "name": "Villager",          "type": "spawn",          "rotation": 0,          "visible": true,          "x": 160,          "y": 208,          "width": 16,          "height": 16,          "properties": []        }      ]    }  ]}
tools/build-2d-assets.ts
// Writes every file the four 2D examples load into `examples/assets/2d/`. Run it from the// repository root, after fetching the two Kenney sheets the way `examples/assets/README.md`// records:////   node examples/tilemap/tools/build-2d-assets.ts \//     --tiny-town /tmp/kenney/tiny-town/Tilemap/tilemap.png \//     --pixel-platformer /tmp/kenney/pixel-platformer/Tilemap/tilemap.png//// ## Why the four 2D examples share one generator, and why it lives here//// `examples/_tools/` belongs to the examples pipeline rather than to an example author// (`plan/08-execution.md` §5), and a new directory directly under `examples/` is refused by// `test/site.test.ts` because it has no catalogue entry. An example-local helper// is what is left, so it sits in the directory of the example that needs the whole pipeline —// `tilemap` — and serves `sprite-animation`, `platformer-controller` and `physics-2d` too, because// they share its sheets.//// ## What it does, and what it deliberately does not do//// Three kinds of work, each of them the documented ignifx way of getting art into the engine://// 1. **Crops** each Kenney sheet to the rectangular sub-grid the examples actually draw from, so//    only the tiles in use are vendored (`04-examples-platform.md` §5.1). A rectangular crop keeps//    the grid intact, which is what lets `gridAtlas` address it.// 2. **Cuts** every grid sheet into an `ignifx.spriteatlas` document with `gridAtlas`. The frame//    names are `<namePrefix>_<index>`, which is exactly what `@ignifx/2d`'s Tiled importer emits//    for a tileset of that name — that contract is what lets `TilemapRenderer` resolve a tile's//    sprite with no side table.// 3. **Converts** each hand-authored Tiled map with `importTiledMap`, then run-length encodes its//    layers. The importer is a pure function over parsed JSON with no file system, no network and//    no asset system, so it belongs in a build step rather than in a game's first frame: the//    runtime loads the `ignifx.tilemap` document it produced.//// It does not resample, recolour or repack anything: every pixel it writes came out of a source// image unchanged, so a digest of an output is a digest of a crop.import { createHash } from "node:crypto";import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";import path from "node:path";import process from "node:process";import { encodeTileRle, gridAtlas, importTiledMap } from "ignifx";import sharp from "sharp";import type { SpriteAtlasDefinition, TilemapDefinition, TilemapLayerInput } from "ignifx";import { engineFile } from "../../../scripts/engine.ts";/** The `examples` directory, two levels above this file's own directory. */const EXAMPLES = path.resolve(import.meta.dirname, "..", "..");/** Where every file this script writes lands. */const OUT = path.join(EXAMPLES, "assets", "2d");/** * Resolves one of the engine's template art files from the installed `@ignifx/cli`. * * @param repoPath - The file's path in the engine repository. * @returns An absolute path. * @throws When the installed packages do not publish it. */function engineArt(repoPath: string): string {  const file = engineFile(repoPath);  if (file === null) {    throw new Error(`the installed engine packages do not publish ${repoPath}.`);  }  return file;}/** One rectangular crop of a grid sheet, in cells. */interface Crop {  /** How many columns of the grid to keep, counting from the left. */  readonly columns: number;  /** How many rows to keep, counting from the top. */  readonly rows: number;  /** One cell's size, in pixels. Square in both packs. */  readonly cell: number;  /** The gap between adjacent cells, in pixels. */  readonly spacing: number;}/** * The Kenney **Tiny Town** crop: the first eight rows of the sheet — grass, dirt, trees, roofs, * walls, doors and fences — which is everything the village is built from. The three rows left * behind are castle walls, arches and inventory props. */const TINY_TOWN: Crop = { columns: 12, rows: 8, cell: 16, spacing: 1 };/** * The Kenney **Pixel Platformer** crop: the first twelve columns of the first eight rows, which * holds the ground tiles, the crates, the planks and the coin. The eight columns left behind are * water, foliage and a number font. */const PIXEL_PLATFORMER: Crop = { columns: 12, rows: 8, cell: 18, spacing: 1 };/** The engine repository's own generated template art, copied rather than regenerated. */const REPO_ART = {  /** The top-down character: four facings, an eight-frame walk and a two-frame idle. */  villager: "templates/2d-topdown/assets/hero",  /** The side-on character: a four-frame idle, an eight-frame run, and a jump and fall pose. */  runner: "templates/2d-sidescroller/assets/hero",  /** The side-on terrain: ground, dirt, two slopes, a one-way plank, brick and two edges. */  terrain: "templates/2d-sidescroller/assets/tiles",} as const;/** * The pixel size of a crop's output image. * * @param crop - The crop. * @returns The width and height in pixels. */function cropSize(crop: Crop): { readonly width: number; readonly height: number } {  const span = (count: number): number => count * (crop.cell + crop.spacing) - crop.spacing;  return { width: span(crop.columns), height: span(crop.rows) };}/** * Reads a PNG's dimensions out of its IHDR chunk. * * @remarks * `sharp` could answer this, and does for the crops; this exists for the files that are copied * byte for byte, where loading them into an image pipeline only to ask their size would be the * long way round. * * @param file - The PNG's path. * @returns Its width and height in pixels. * @throws An `Error` when the file is not a PNG. */async function pngSize(file: string): Promise<{ readonly width: number; readonly height: number }> {  const bytes = await readFile(file);  if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49_48_44_52) {    throw new Error(`${file} is not a PNG: its first chunk is not IHDR.`);  }  return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };}/** * Writes a JSON document with a trailing newline, and reports it. * * @param name - The file's name inside `assets/2d/`. * @param value - The document. */async function writeJson(name: string, value: unknown): Promise<void> {  await writeFile(path.join(OUT, name), `${JSON.stringify(value, null, 2)}\n`);  await report(name);}/** * Prints one output file's size and digest, which is what `ATTRIBUTION.md` records. * * @param name - The file's name inside `assets/2d/`. */async function report(name: string): Promise<void> {  const file = path.join(OUT, name);  const [bytes, info] = await Promise.all([readFile(file), stat(file)]);  const digest = createHash("sha256").update(bytes).digest("hex");  process.stdout.write(`${`2d/${name}`.padEnd(30)} ${String(info.size).padStart(7)} bytes  ${digest}\n`);}/** * Crops one grid sheet and cuts the result into an atlas document. * * @param source - The sheet on disk. * @param stem - The output name, without an extension. * @param crop - How much of the grid to keep. * @param prefix - The `<prefix>_<index>` frame names, which is the tileset's name in a Tiled map. */async function buildSheet(source: string, stem: string, crop: Crop, prefix: string): Promise<void> {  const size = cropSize(crop);  const cropped = await sharp(source)    .extract({ left: 0, top: 0, width: size.width, height: size.height })    .png({ compressionLevel: 9, effort: 10 })    .toBuffer();  await writeFile(path.join(OUT, `${stem}.png`), cropped);  await report(`${stem}.png`);  await writeJson(    `${stem}.atlas.json`,    gridAtlas({      // Relative, so the address resolves through the asset manifest and a content-hashed build      // still finds the sheet (`packages/2d/src/atlas/loader.ts`, `resolveAtlasImageUrl`).      image: `${stem}.png`,      imageWidth: size.width,      imageHeight: size.height,      cellWidth: crop.cell,      cellHeight: crop.cell,      spacing: crop.spacing,      namePrefix: prefix,      // A texture's sampler is fixed at upload, so pixel art has to say so here; `pixelPerfect` on      // the camera does not change it.      sampling: "nearest",    }) satisfies SpriteAtlasDefinition,  );}/** * Copies one of the repository's own generated sheets and cuts it into a grid atlas. * * @param stem - The output name, without an extension. * @param source - The sheet's path under the repository root, without an extension. * @param cell - One cell's size in pixels. * @param margin - The border around the whole grid, in pixels. * @param spacing - The gap between adjacent cells, in pixels. * @param prefix - The frame-name prefix. */async function copyGridSheet(  stem: string,  source: string,  cell: number,  margin: number,  spacing: number,  prefix: string,): Promise<void> {  const image = engineArt(`${source}.png`);  await copyFile(image, path.join(OUT, `${stem}.png`));  await report(`${stem}.png`);  const size = await pngSize(image);  await writeJson(    `${stem}.atlas.json`,    gridAtlas({      image: `${stem}.png`,      imageWidth: size.width,      imageHeight: size.height,      cellWidth: cell,      cellHeight: cell,      margin,      spacing,      namePrefix: prefix,      sampling: "nearest",    }) satisfies SpriteAtlasDefinition,  );}/** * Copies one of the repository's own generated characters: the sheet, its hand-packed atlas and its * clips, with each document's reference to the next rewritten to the new name. * * @remarks * The atlas is copied rather than re-cut, because its frames carry meaning — `idle_0`, `run_3`, * `jump` — and a grid atlas would name them `runner_0` to `runner_15`. A `.spriteanim.json`'s clips * address frames by name, so the names are the document. * * @param stem - The output name, without an extension. * @param source - The sheet's path under the repository root, without an extension. */async function copyCharacter(stem: string, source: string): Promise<void> {  await copyFile(engineArt(`${source}.png`), path.join(OUT, `${stem}.png`));  await report(`${stem}.png`);  const atlas: unknown = JSON.parse(await readFile(engineArt(`${source}.atlas.json`), "utf8"));  if (typeof atlas !== "object" || atlas === null) {    throw new Error(`${source}.atlas.json is not a JSON object.`);  }  await writeJson(`${stem}.atlas.json`, { ...atlas, image: `${stem}.png` });  const clips: unknown = JSON.parse(await readFile(engineArt(`${source}.spriteanim.json`), "utf8"));  if (typeof clips !== "object" || clips === null) {    throw new Error(`${source}.spriteanim.json is not a JSON object.`);  }  await writeJson(`${stem}.spriteanim.json`, { ...clips, atlas: `${stem}.atlas.json` });}/** * Run-length encodes every layer of an imported map, so the committed document is a grid rather * than a wall of zeros. * * @param map - What `importTiledMap` produced. * @returns The document to write. */function packLayers(map: TilemapDefinition): unknown {  const layers: TilemapLayerInput[] = [];  for (const layer of map.layers) {    layers.push({ ...layer, tiles: { rle: [...encodeTileRle(layer.tiles)] } });  }  return { ...map, layers };}/** * Converts one hand-authored Tiled map into an `ignifx.tilemap` document. * * @param slug - The example directory holding the `.tmj.json` source. * @param stem - Both the source's and the output's name, without an extension. * @param pixelsPerUnit - How many pixels one world metre spans, which fixes the map's `cellSize`. */async function buildMap(slug: string, stem: string, pixelsPerUnit: number): Promise<void> {  const source = path.join(EXAMPLES, slug, `${stem}.tmj.json`);  const tmj: unknown = JSON.parse(await readFile(source, "utf8"));  await writeJson(    `${stem}.tilemap.json`,    packLayers(      importTiledMap(tmj, {        pixelsPerUnit,        // Every tile layer in both maps names its own sorting layer through a Tiled property, so        // this default is only what an unlabelled layer would land in.        sortingLayer: "Terrain",        // The tileset's `image` names the sheet; the atlas beside it is what ignifx loads. The        // default mapping would answer `tiny-town.atlas.json`, which is already right, and naming        // it here is what makes that a decision rather than a coincidence.        atlasFor: (image: string): string => image.replace(/\.png$/u, ".atlas.json"),      }),    ),  );}/** * Reads one required `--flag value` pair out of the command line. * * @param flag - The flag, including its dashes. * @returns The value. * @throws An `Error` when the flag is absent or has no value. */function argument(flag: string): string {  const index = process.argv.indexOf(flag);  const value = index < 0 ? undefined : process.argv[index + 1];  if (value === undefined || value.startsWith("--")) {    throw new Error(`${flag} <path> is required; see the header of this file.`);  }  return value;}await mkdir(OUT, { recursive: true });await buildSheet(argument("--tiny-town"), "tiny-town", TINY_TOWN, "town");await buildSheet(argument("--pixel-platformer"), "props", PIXEL_PLATFORMER, "props");await copyGridSheet("terrain", REPO_ART.terrain, 16, 1, 2, "terrain");await copyCharacter("villager", REPO_ART.villager);await copyCharacter("runner", REPO_ART.runner);await buildMap("tilemap", "village", 16);await buildMap("platformer-controller", "course", 16);
Uses
TilemapTilemapRendererTilemapCollider2DCamera2DCamera2DFollowCharacterController2DSpriteAnimatorspawnTilemapObjectsVirtualJoystickimportTiledMap
Assets
Tiny Town tileset — CC0 1.0, KenneyVillager sprite sheet — Apache-2.0, Astrum Forge Studios