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:
Axel Meyer
2026-05-23 16:45:06 +02:00
parent e96431b44f
commit 7adcb407fa
2 changed files with 233 additions and 0 deletions

100
src/builder.js Normal file
View 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 };