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.
This commit is contained in:
100
src/builder.js
Normal file
100
src/builder.js
Normal file
@@ -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 <name>' 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 };
|
||||
133
tests/builder.test.js
Normal file
133
tests/builder.test.js
Normal file
@@ -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/)
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user