From 7adcb407fa5de5a88458609f0d6b31033dcac7e9 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Sat, 23 May 2026 16:45:06 +0200 Subject: [PATCH] Add map builder that turns AST + atlas registry into v2 JSON Validates atlas declarations against the registry, resolves tile names per atlas, bounds-checks each cell, and emits a flat row-major (y*w + x) GID array per layer. Roof block is only serialised when at least one roof directive was present so empty maps stay compact. --- src/builder.js | 100 +++++++++++++++++++++++++++++++ tests/builder.test.js | 133 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/builder.js create mode 100644 tests/builder.test.js diff --git a/src/builder.js b/src/builder.js new file mode 100644 index 0000000..6185be7 --- /dev/null +++ b/src/builder.js @@ -0,0 +1,100 @@ +'use strict'; + +const { encodeGid } = require('./gid'); + +class BuildError extends Error { + constructor(line, message) { + super(message); + this.line = line; + } +} + +function buildMap(ast, registry, opts = {}) { + if (ast.atlases.length === 0) { + throw new BuildError(1, `at least one 'atlas' declaration required (atlases[] must be non-empty)`); + } + + // Resolve declared atlas_ids against registry, build atlasId → atlas_index map. + const atlasIndex = {}; + ast.atlases.forEach((atlasId, idx) => { + if (!registry[atlasId]) { + throw new BuildError(1, `atlas '${atlasId}' declared in DSL but not found in atlas registry (check --atlas-dir paths)`); + } + atlasIndex[atlasId] = idx; + }); + + const id = ast.id || opts.defaultId; + if (!id || typeof id !== 'string' || id.length === 0) { + throw new BuildError(1, `map id is required (declare with 'id ' in the DSL or pass a non-empty out-file basename)`); + } + + const { w, h } = ast.size; + const cellCount = w * h; + + function resolveTile(atlasId, tileName, line) { + const atlas = registry[atlasId]; + if (!atlas) { + throw new BuildError(line, `atlas '${atlasId}' not declared in DSL`); + } + const tileId = atlas.tilesByName[tileName]; + if (tileId === undefined) { + throw new BuildError(line, `tile name '${tileName}' not found in atlas '${atlasId}'`); + } + return tileId; + } + + function checkBounds(x, y, line) { + if (x < 0 || y < 0 || x >= w || y >= h) { + throw new BuildError(line, `cell (${x},${y}) out of bounds for size ${w}x${h}`); + } + } + + // Build each layer. + const layers = {}; + for (const [layerName, directives] of Object.entries(ast.layers)) { + const tiles = new Array(cellCount).fill(0); + for (const d of directives) { + const ai = atlasIndex[d.atlasId]; + if (ai === undefined) { + throw new BuildError(d.line, `atlas '${d.atlasId}' not declared in DSL`); + } + const tileId = resolveTile(d.atlasId, d.tileName, d.line); + const gid = encodeGid(ai, tileId, d.rot); + if (d.type === 'set') { + checkBounds(d.x, d.y, d.line); + tiles[d.y * w + d.x] = gid; + } else { + // fill — bounds-check the corners; parser already enforced x0<=x1 and y0<=y1 + checkBounds(d.x0, d.y0, d.line); + checkBounds(d.x1, d.y1, d.line); + for (let y = d.y0; y <= d.y1; y++) { + for (let x = d.x0; x <= d.x1; x++) { + tiles[y * w + x] = gid; + } + } + } + } + layers[layerName] = { tiles }; + } + + const map = { + schema_version: 2, + id, + size: { w, h }, + atlases: ast.atlases.slice(), + layers, + }; + + if (ast.roof.length > 0) { + const roof = new Array(cellCount).fill(0); + for (const r of ast.roof) { + checkBounds(r.x, r.y, r.line); + roof[r.y * w + r.x] = r.value; + } + map.roof = roof; + } + + return map; +} + +module.exports = { buildMap, BuildError }; diff --git a/tests/builder.test.js b/tests/builder.test.js new file mode 100644 index 0000000..a198748 --- /dev/null +++ b/tests/builder.test.js @@ -0,0 +1,133 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { parseDsl } = require('../src/dsl-parser'); +const { loadAtlasDirs } = require('../src/atlas-loader'); +const { buildMap } = require('../src/builder'); +const { encodeGid } = require('../src/gid'); + +const FIX_ATLAS = path.join(__dirname, 'fixtures', 'atlases'); +const { registry } = loadAtlasDirs([FIX_ATLAS]); + +function build(src, opts = {}) { + const ast = parseDsl(src); + return buildMap(ast, registry, opts); +} + +test('buildMap: minimal map produces schema-v2 shape', () => { + const map = build('size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\n', { defaultId: 'fallback' }); + assert.equal(map.schema_version, 2); + assert.equal(map.id, 'fallback'); + assert.deepEqual(map.size, { w: 2, h: 2 }); + assert.deepEqual(map.atlases, ['atlas_a']); + assert.equal(map.layers.surface.tiles.length, 4); + // Cell (0,0) is index 0 (row-major: y*w + x) + assert.equal(map.layers.surface.tiles[0], encodeGid(0, 1, 0)); + assert.equal(map.layers.surface.tiles[1], 0); // (1,0) empty +}); + +test('buildMap: AST id wins over defaultId', () => { + const map = build('id from_dsl\nsize 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\n', { defaultId: 'fallback' }); + assert.equal(map.id, 'from_dsl'); +}); + +test('buildMap: fill produces correct row-major GID array', () => { + const src = 'size 3 3\natlas atlas_a\nlayer surface\n fill 0 0 2 2 atlas_a:stone\n'; + const map = build(src, { defaultId: 'test' }); + const gid = encodeGid(0, 2, 0); + for (let i = 0; i < 9; i++) assert.equal(map.layers.surface.tiles[i], gid, `cell ${i}`); +}); + +test('buildMap: rotation encodes into GID', () => { + const src = 'size 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass rot 3\n'; + const map = build(src, { defaultId: 't' }); + assert.equal(map.layers.surface.tiles[0], encodeGid(0, 1, 3)); +}); + +test('buildMap: multiple atlases get correct atlas_index', () => { + const src = 'size 1 1\natlas atlas_a\natlas atlas_b\nlayer surface\n set 0 0 atlas_b:water\n'; + const map = build(src, { defaultId: 't' }); + // atlas_b is index 1 in declaration order + assert.equal(map.layers.surface.tiles[0], encodeGid(1, 1, 0)); +}); + +test('buildMap: roof block produces flat array of size w*h', () => { + const src = 'size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\nroof\n set 1 1 1\n'; + const map = build(src, { defaultId: 't' }); + assert.equal(map.roof.length, 4); + assert.equal(map.roof[3], 1); + assert.equal(map.roof[0], 0); +}); + +test('buildMap: empty layers are not serialised', () => { + const src = 'size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\n'; + const map = build(src, { defaultId: 't' }); + assert.deepEqual(Object.keys(map.layers), ['surface']); + assert.equal(map.roof, undefined); +}); + +test('buildMap: unknown atlas reference is an error', () => { + const src = 'size 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_c:grass\n'; + assert.throws( + () => build(src, { defaultId: 't' }), + (err) => err.message.match(/atlas_c.*not declared/) && err.line === 4 + ); +}); + +test('buildMap: undeclared atlas in DSL is an error', () => { + const src = 'size 1 1\natlas missing_atlas\nlayer surface\n set 0 0 missing_atlas:grass\n'; + assert.throws( + () => build(src, { defaultId: 't' }), + (err) => !!err.message.match(/missing_atlas.*registry/) + ); +}); + +test('buildMap: unknown tile name in atlas is an error', () => { + const src = 'size 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:nonexistent\n'; + assert.throws( + () => build(src, { defaultId: 't' }), + (err) => err.message.match(/nonexistent/) && err.line === 4 + ); +}); + +test('buildMap: out-of-bounds set is an error', () => { + const src = 'size 4 4\natlas atlas_a\nlayer surface\n set 5 0 atlas_a:grass\n'; + assert.throws( + () => build(src, { defaultId: 't' }), + (err) => err.message.match(/out of bounds/) && err.line === 4 + ); +}); + +test('buildMap: out-of-bounds fill is an error', () => { + const src = 'size 4 4\natlas atlas_a\nlayer surface\n fill 0 0 4 4 atlas_a:grass\n'; + assert.throws( + () => build(src, { defaultId: 't' }), + (err) => err.message.match(/out of bounds/) && err.line === 4 + ); +}); + +test('buildMap: out-of-bounds roof set is an error', () => { + const src = 'size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\nroof\n set 2 2 1\n'; + assert.throws( + () => build(src, { defaultId: 't' }), + (err) => err.message.match(/out of bounds/) && err.line === 6 + ); +}); + +test('buildMap: no atlases declared is an error', () => { + const src = 'size 1 1\n'; + assert.throws( + () => build(src, { defaultId: 't' }), + (err) => !!err.message.match(/atlases.*non-empty/) + ); +}); + +test('buildMap: empty defaultId AND no DSL id is an error', () => { + const src = 'size 1 1\natlas atlas_a\n'; + assert.throws( + () => build(src, { defaultId: '' }), + (err) => !!err.message.match(/id/) + ); +});