Add map inspector that emits a human-readable stats report
Counts set vs empty cells per layer, lists the top-10 most frequent non-empty GIDs with decoded components, and reports roof coverage. When an atlas registry is supplied, atlas-id+tile-name annotations are added; without one the report still works using indices only.
This commit is contained in:
77
src/inspector.js
Normal file
77
src/inspector.js
Normal file
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
const { decodeGid } = require('./gid');
|
||||
|
||||
function tileLabel(atlasIdx, tileId, atlasIdsByIndex, registry) {
|
||||
const atlasId = atlasIdsByIndex[atlasIdx];
|
||||
const reg = atlasId ? registry[atlasId] : null;
|
||||
const name = reg ? reg.tilesById[tileId] : null;
|
||||
const components = `atlas=${atlasIdx} tile=${tileId}`;
|
||||
if (atlasId && name) return `${components.padEnd(20)} (${atlasId}:${name})`;
|
||||
if (atlasId) return `${components.padEnd(20)} (${atlasId}:?)`;
|
||||
return components;
|
||||
}
|
||||
|
||||
function atlasResolutionMarker(atlasId, registry) {
|
||||
if (!registry || Object.keys(registry).length === 0) return '(no registry)';
|
||||
if (registry[atlasId]) return '(resolved)';
|
||||
return '(not found)';
|
||||
}
|
||||
|
||||
function inspectMap(map, registry) {
|
||||
const lines = [];
|
||||
const reg = registry || {};
|
||||
const atlasIdsByIndex = map.atlases || [];
|
||||
|
||||
lines.push(`Map: ${map.id} (schema_version=${map.schema_version}, size=${map.size.w}x${map.size.h})`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('Atlases:');
|
||||
if (atlasIdsByIndex.length === 0) {
|
||||
lines.push(' (none declared)');
|
||||
} else {
|
||||
atlasIdsByIndex.forEach((aid, i) => {
|
||||
lines.push(` [${i}] ${aid.padEnd(40)} ${atlasResolutionMarker(aid, reg)}`);
|
||||
});
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('Layers:');
|
||||
const layerEntries = Object.entries(map.layers || {});
|
||||
if (layerEntries.length === 0) {
|
||||
lines.push(' (no layers)');
|
||||
} else {
|
||||
for (const [layerName, layer] of layerEntries) {
|
||||
const tiles = layer.tiles || [];
|
||||
let set = 0, empty = 0;
|
||||
const counts = new Map();
|
||||
for (const gid of tiles) {
|
||||
if (gid === 0) { empty++; continue; }
|
||||
set++;
|
||||
counts.set(gid, (counts.get(gid) || 0) + 1);
|
||||
}
|
||||
lines.push(` ${layerName.padEnd(13)} ${String(set).padStart(3)} cells set, ${String(empty).padStart(3)} empty`);
|
||||
if (counts.size > 0) {
|
||||
lines.push(' top tiles:');
|
||||
const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
for (const [gid, n] of top) {
|
||||
const { atlas, tile } = decodeGid(gid);
|
||||
lines.push(` ${tileLabel(atlas, tile, atlasIdsByIndex, reg)} × ${n}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
const roof = map.roof;
|
||||
if (Array.isArray(roof)) {
|
||||
const flagged = roof.reduce((a, v) => a + (v ? 1 : 0), 0);
|
||||
lines.push(`Roof: ${flagged} cells flagged`);
|
||||
} else {
|
||||
lines.push('Roof: (none)');
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
module.exports = { inspectMap };
|
||||
10
tests/fixtures/atlases-inspector/atlas_a/tiles.atlas.json
vendored
Normal file
10
tests/fixtures/atlases-inspector/atlas_a/tiles.atlas.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"atlas_id": "atlas_a",
|
||||
"atlas_version": 1,
|
||||
"atlas_size_px": [64, 64],
|
||||
"tile_size_px": 32,
|
||||
"tiles": [
|
||||
{ "id": 1, "name": "grass", "uv": [0, 0, 32, 32], "walkable": true },
|
||||
{ "id": 2, "name": "stone", "uv": [32, 0, 32, 32], "walkable": false, "blocks_sight": true }
|
||||
]
|
||||
}
|
||||
30
tests/fixtures/maps/inspect_sample.map.json
vendored
Normal file
30
tests/fixtures/maps/inspect_sample.map.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"id": "inspect_sample",
|
||||
"size": { "w": 4, "h": 4 },
|
||||
"atlases": ["atlas_a", "atlas_b"],
|
||||
"layers": {
|
||||
"surface": {
|
||||
"tiles": [
|
||||
16, 16, 16, 16,
|
||||
16, 32, 32, 16,
|
||||
16, 32, 32, 16,
|
||||
16, 16, 16, 16
|
||||
]
|
||||
},
|
||||
"wall": {
|
||||
"tiles": [
|
||||
16777232, 16777232, 16777232, 16777232,
|
||||
16777232, 0, 0, 16777232,
|
||||
16777232, 0, 0, 16777232,
|
||||
16777232, 16777232, 16777232, 16777232
|
||||
]
|
||||
}
|
||||
},
|
||||
"roof": [
|
||||
0, 0, 0, 0,
|
||||
0, 1, 1, 0,
|
||||
0, 1, 1, 0,
|
||||
0, 0, 0, 0
|
||||
]
|
||||
}
|
||||
76
tests/inspector.test.js
Normal file
76
tests/inspector.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { inspectMap } = require('../src/inspector');
|
||||
const { loadAtlasDirs } = require('../src/atlas-loader');
|
||||
|
||||
const FIX_MAP = path.join(__dirname, 'fixtures', 'maps', 'inspect_sample.map.json');
|
||||
const FIX_ATLAS = path.join(__dirname, 'fixtures', 'atlases-inspector');
|
||||
const loadMap = () => JSON.parse(fs.readFileSync(FIX_MAP, 'utf8'));
|
||||
|
||||
test('inspectMap: header lists id, schema_version, size', () => {
|
||||
const text = inspectMap(loadMap(), {});
|
||||
assert.match(text, /Map: inspect_sample/);
|
||||
assert.match(text, /schema_version=2/);
|
||||
assert.match(text, /size=4x4/);
|
||||
});
|
||||
|
||||
test('inspectMap: atlas section lists each declared atlas with resolved/not-found marker', () => {
|
||||
const { registry } = loadAtlasDirs([FIX_ATLAS]);
|
||||
const text = inspectMap(loadMap(), registry);
|
||||
assert.match(text, /\[0\] atlas_a.*resolved/);
|
||||
// atlas_b is not in the FIX_ATLAS fixture dir
|
||||
assert.match(text, /\[1\] atlas_b.*not found/);
|
||||
});
|
||||
|
||||
test('inspectMap: without registry, atlases show "no registry" marker', () => {
|
||||
const text = inspectMap(loadMap(), {});
|
||||
assert.match(text, /\[0\] atlas_a.*no registry/);
|
||||
});
|
||||
|
||||
test('inspectMap: per-layer cell counts are correct', () => {
|
||||
const text = inspectMap(loadMap(), {});
|
||||
// surface has all 16 cells set
|
||||
assert.match(text, /surface\s+16 cells set,\s+0 empty/);
|
||||
// wall has 12 set, 4 empty (the 2x2 hole)
|
||||
assert.match(text, /wall\s+12 cells set,\s+4 empty/);
|
||||
});
|
||||
|
||||
test('inspectMap: top tiles include atlas/tile components', () => {
|
||||
const text = inspectMap(loadMap(), {});
|
||||
// surface tile (0,1,0) = grass appears 12 times
|
||||
assert.match(text, /atlas=0 tile=1.*× ?12/);
|
||||
// surface tile (0,2,0) = stone appears 4 times
|
||||
assert.match(text, /atlas=0 tile=2.*× ?4/);
|
||||
});
|
||||
|
||||
test('inspectMap: with registry, top tiles show resolved name', () => {
|
||||
const { registry } = loadAtlasDirs([FIX_ATLAS]);
|
||||
const text = inspectMap(loadMap(), registry);
|
||||
assert.match(text, /atlas_a:grass/);
|
||||
assert.match(text, /atlas_a:stone/);
|
||||
});
|
||||
|
||||
test('inspectMap: roof line shows flagged-cell count', () => {
|
||||
const text = inspectMap(loadMap(), {});
|
||||
assert.match(text, /Roof: 4 cells flagged/);
|
||||
});
|
||||
|
||||
test('inspectMap: map without roof omits roof section gracefully', () => {
|
||||
const m = loadMap();
|
||||
delete m.roof;
|
||||
const text = inspectMap(m, {});
|
||||
assert.match(text, /Roof:\s*0 cells flagged|Roof: \(none\)/);
|
||||
});
|
||||
|
||||
test('inspectMap: empty layers are not listed as top-tile groups', () => {
|
||||
const m = loadMap();
|
||||
m.layers.topsurface = { tiles: new Array(16).fill(0) };
|
||||
const text = inspectMap(m, {});
|
||||
assert.match(text, /topsurface\s+0 cells set,\s+16 empty/);
|
||||
// The "top tiles" block under topsurface should be absent (or "none")
|
||||
assert.doesNotMatch(text.split('topsurface')[1].split(/\n[a-z]/)[0], /atlas=\d+ tile=\d+/);
|
||||
});
|
||||
Reference in New Issue
Block a user