Skip to content

Instantly share code, notes, and snippets.

@Kattoor
Created March 11, 2026 14:45
Show Gist options
  • Select an option

  • Save Kattoor/05776686192f36adf1a4eedfdd6f4543 to your computer and use it in GitHub Desktop.

Select an option

Save Kattoor/05776686192f36adf1a4eedfdd6f4543 to your computer and use it in GitHub Desktop.
map-and-unit-stitcher.js
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const MAP_TILE_CHUNKS_PER_SIDE = 3;
function parseArgs(argv) {
const positional = [];
const flags = {};
for (const a of argv) {
if (a.startsWith("--")) {
const [k, v] = a.slice(2).split("=");
flags[k] = v === undefined ? true : v;
} else {
positional.push(a);
}
}
return { positional, flags };
}
function floorDiv(a, b) {
return Math.floor(a / b);
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function parseMinimapTileName(file) {
const m = file.match(/^(-?\d+)_(-?\d+)_(\d+)\.png$/i);
if (!m) return null;
return {
tileX: parseInt(m[1], 10),
tileY: parseInt(m[2], 10),
tileRes: parseInt(m[3], 10),
};
}
function parseChunkFromPath(p) {
const parts = p.split(/[\\/]/);
for (const seg of parts) {
const m = seg.match(/^L(\d+)_([+-]?\d+)_([+-]?\d+)$/);
if (m) {
return {
level: parseInt(m[1], 10),
I: parseInt(m[2], 10),
J: parseInt(m[3], 10),
id: seg,
};
}
}
return null;
}
function findNodes(root, predicate, out = []) {
if (!root || typeof root !== "object") return out;
if (predicate(root)) out.push(root);
if (Array.isArray(root.children)) {
for (const c of root.children) findNodes(c, predicate, out);
}
return out;
}
function loadWorldParams(worldPrefabPath) {
const world = readJson(worldPrefabPath);
const terrainNodes = findNodes(
world,
n => n && typeof n === "object" && n.type === "gterrain"
);
if (!terrainNodes.length) {
throw new Error("Could not find gterrain node in world prefab.");
}
const worldNodes = findNodes(
world,
n => n && typeof n === "object" && n.type === "world" && n.name === "gameplayData"
);
if (!worldNodes.length) {
throw new Error("Could not find gameplayData world node in world prefab.");
}
const terrain = terrainNodes[0];
const gameplay = worldNodes[0];
const terrainCellsPerFilePow = terrain.cellsPerFilePow;
if (typeof terrainCellsPerFilePow !== "number") {
throw new Error("terrain.cellsPerFilePow missing in world prefab.");
}
const gameplayWorldUnit =
typeof gameplay.worldUnit === "number" ? gameplay.worldUnit : 1;
// Class default from prefab.World
const gameplayCellsPerFilePow = 5;
const gameplayBaseChunkSize =
(1 << gameplayCellsPerFilePow) * gameplayWorldUnit;
const terrainChunkWidth = (1 << terrainCellsPerFilePow) * 3;
const tileWorld = terrainChunkWidth * MAP_TILE_CHUNKS_PER_SIDE;
const loadedChunkIds = new Set(
Array.isArray(gameplay.chunkData)
? gameplay.chunkData.map(x => x.id)
: []
);
return {
terrainCellsPerFilePow,
gameplayCellsPerFilePow,
gameplayWorldUnit,
gameplayBaseChunkSize,
terrainChunkWidth,
tileWorld,
loadedChunkIds,
};
}
async function listFilesRecursive(rootDir) {
const out = [];
async function walk(dir) {
const ents = await fs.promises.readdir(dir, { withFileTypes: true });
for (const e of ents) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(p);
} else {
out.push(p);
}
}
}
await walk(rootDir);
return out;
}
async function stitchMinimap(minimapDir, outPath, tileWorld) {
const files = await fs.promises.readdir(minimapDir);
const tiles = [];
for (const f of files) {
const info = parseMinimapTileName(f);
if (!info) continue;
tiles.push({
...info,
filePath: path.join(minimapDir, f),
});
}
if (!tiles.length) {
throw new Error(`No minimap tiles found in ${minimapDir}`);
}
const meta0 = await sharp(tiles[0].filePath).metadata();
const tilePx = meta0.width;
if (!tilePx || tilePx !== meta0.height) {
throw new Error("Minimap tiles must be square.");
}
let minTX = Infinity;
let maxTX = -Infinity;
let minTY = Infinity;
let maxTY = -Infinity;
for (const t of tiles) {
minTX = Math.min(minTX, t.tileX);
maxTX = Math.max(maxTX, t.tileX);
minTY = Math.min(minTY, t.tileY);
maxTY = Math.max(maxTY, t.tileY);
}
const outW = (maxTX - minTX + 1) * tilePx;
const outH = (maxTY - minTY + 1) * tilePx;
const composites = tiles.map(t => ({
input: t.filePath,
left: (t.tileX - minTX) * tilePx,
top: (t.tileY - minTY) * tilePx,
}));
await sharp({
create: {
width: outW,
height: outH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(composites)
.png({ compressionLevel: 9 })
.toFile(outPath);
return {
tileWorld,
tilePx,
pxPerWorld: tilePx / tileWorld,
minTX,
maxTX,
minTY,
maxTY,
outW,
outH,
};
}
function worldToPixel(worldX, worldY, meta) {
const { tileWorld, tilePx, pxPerWorld, minTX, minTY } = meta;
const tileX = floorDiv(worldX, tileWorld);
const tileY = floorDiv(worldY, tileWorld);
const localX = worldX - tileX * tileWorld;
const localY = worldY - tileY * tileWorld;
const px = (tileX - minTX) * tilePx + localX * pxPerWorld;
const py = (tileY - minTY) * tilePx + localY * pxPerWorld;
return { tileX, tileY, px, py };
}
// 2D affine transform
function identityT() {
return { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
}
function mulT(p, l) {
return {
a: p.a * l.a + p.c * l.b,
b: p.b * l.a + p.d * l.b,
c: p.a * l.c + p.c * l.d,
d: p.b * l.c + p.d * l.d,
tx: p.a * l.tx + p.c * l.ty + p.tx,
ty: p.b * l.tx + p.d * l.ty + p.ty,
};
}
function applyT(t, x, y) {
return {
x: t.a * x + t.c * y + t.tx,
y: t.b * x + t.d * y + t.ty,
};
}
function nodeLocalT(node) {
const x = typeof node.x === "number" ? node.x : 0;
const y = typeof node.y === "number" ? node.y : 0;
const sx =
typeof node.scaleX === "number"
? node.scaleX
: typeof node.scale === "number"
? node.scale
: 1;
const sy =
typeof node.scaleY === "number"
? node.scaleY
: typeof node.scale === "number"
? node.scale
: 1;
let rz = typeof node.rotationZ === "number" ? node.rotationZ : 0;
if (Math.abs(rz) > 6.283185307179586) {
rz = (rz * Math.PI) / 180.0;
}
const cos = Math.cos(rz);
const sin = Math.sin(rz);
return {
a: cos * sx,
b: sin * sx,
c: -sin * sy,
d: cos * sy,
tx: x,
ty: y,
};
}
function traversePrefab(node, parentT, visit) {
if (!node || typeof node !== "object") return;
const absT = mulT(parentT, nodeLocalT(node));
visit(node, absT);
if (Array.isArray(node.children)) {
for (const c of node.children) {
traversePrefab(c, absT, visit);
}
}
}
async function extractUnitSpawns(worldRootDir, unitId, loadedChunkIds) {
const all = await listFilesRecursive(worldRootDir);
const gameplayFiles = all.filter(p => p.endsWith("gameplayData.prefab"));
const spawns = [];
const skippedChunks = new Map();
for (const fp of gameplayFiles) {
const chunk = parseChunkFromPath(fp);
if (!chunk) continue;
if (!loadedChunkIds.has(chunk.id)) {
skippedChunks.set(chunk.id, (skippedChunks.get(chunk.id) || 0) + 1);
continue;
}
let json;
try {
json = readJson(fp);
} catch {
continue;
}
const roots = Array.isArray(json.children) ? json.children : [];
for (const child of roots) {
traversePrefab(child, identityT(), (node, absT) => {
const props = node.props || {};
if (props.$cdbtype !== "spawner") return;
if (props.unit !== unitId) return;
const pos = applyT(absT, 0, 0);
spawns.push({
file: fp,
chunkId: chunk.id,
level: chunk.level,
I: chunk.I,
J: chunk.J,
localAbsX: pos.x,
localAbsY: pos.y,
props,
});
});
}
}
return {
spawns,
skippedChunkIds: [...skippedChunks.keys()].sort(),
};
}
function mapSpawnerToPixel(s, meta, gameplayBaseChunkSize) {
const chunkSize = gameplayBaseChunkSize * (1 << s.level);
// This is the mapping that matched your good result
const worldX = (s.I - 0.5) * chunkSize + s.localAbsX;
const worldY = (s.J - 0.5) * chunkSize + s.localAbsY;
const p = worldToPixel(worldX, worldY, meta);
return {
chunkSize,
worldX,
worldY,
minimapTileX: p.tileX,
minimapTileY: p.tileY,
px: p.px,
py: p.py,
};
}
function makeSvgOverlay(points, width, height, radius) {
const els = points.map(p => {
const cx = p.px.toFixed(2);
const cy = p.py.toFixed(2);
const cross = Math.max(3, Math.floor(radius * 0.8));
return `
<circle cx="${cx}" cy="${cy}" r="${radius}"
fill="rgba(255,0,0,0.75)" stroke="rgba(255,255,255,0.95)" stroke-width="2" />
<line x1="${(p.px - cross).toFixed(2)}" y1="${cy}" x2="${(p.px + cross).toFixed(2)}" y2="${cy}"
stroke="rgba(0,0,0,0.55)" stroke-width="2" />
<line x1="${cx}" y1="${(p.py - cross).toFixed(2)}" x2="${cx}" y2="${(p.py + cross).toFixed(2)}"
stroke="rgba(0,0,0,0.55)" stroke-width="2" />
`;
}).join("\n");
return Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
${els}
</svg>`
);
}
async function main() {
const { positional, flags } = parseArgs(process.argv.slice(2));
let minimapDir, worldRootDir, worldPrefabPath, unitId, outDir;
if (positional.length >= 5) {
[minimapDir, worldRootDir, worldPrefabPath, unitId, outDir] = positional;
} else if (positional.length >= 4) {
[minimapDir, worldRootDir, unitId, outDir] = positional;
worldPrefabPath = "./W1_Siagarta.prefab";
} else {
console.error(
"Usage:\n" +
" node levelmapstitchunits.js <minimapDir> <worldRootDir> <worldPrefabPath> <UnitId> <outDir> [--radius=10]\n" +
"or:\n" +
" node levelmapstitchunits.js <minimapDir> <worldRootDir> <UnitId> <outDir> [--radius=10]"
);
process.exit(1);
}
const radius = flags.radius ? parseInt(flags.radius, 10) : 10;
await fs.promises.mkdir(outDir, { recursive: true });
const stitchedPath = path.join(outDir, "stitched_minimap.png");
const stitchedOutPath = path.join(outDir, `stitched_with_spawns_${unitId}.png`);
const jsonOutPath = path.join(outDir, `spawns_${unitId}_mapped.json`);
console.log("[1/4] Reading world params");
const params = loadWorldParams(worldPrefabPath);
console.log(
` gameplayBaseChunkSize=${params.gameplayBaseChunkSize}, terrainChunkWidth=${params.terrainChunkWidth}, tileWorld=${params.tileWorld}`
);
console.log(` loaded chunk ids: ${params.loadedChunkIds.size}`);
console.log("[2/4] Stitching minimap");
const meta = await stitchMinimap(minimapDir, stitchedPath, params.tileWorld);
console.log(
` tilePx=${meta.tilePx} bounds x[${meta.minTX}..${meta.maxTX}] y[${meta.minTY}..${meta.maxTY}] pxPerWorld=${meta.pxPerWorld}`
);
console.log(`[3/4] Loading spawns (${unitId})`);
const { spawns, skippedChunkIds } = await extractUnitSpawns(
worldRootDir,
unitId,
params.loadedChunkIds
);
console.log(` kept ${spawns.length} referenced spawner(s)`);
console.log(` skipped ${skippedChunkIds.length} unreferenced chunk(s)`);
const mapped = spawns.map(s => ({
...s,
...mapSpawnerToPixel(s, meta, params.gameplayBaseChunkSize),
}));
const inside = mapped.filter(
p => p.px >= 0 && p.py >= 0 && p.px < meta.outW && p.py < meta.outH
);
console.log(` inside stitched image: ${inside.length}/${mapped.length}`);
await fs.promises.writeFile(
jsonOutPath,
JSON.stringify(
{
params: {
...params,
loadedChunkIds: [...params.loadedChunkIds].sort(),
},
skippedChunkIds,
meta,
points: mapped,
},
null,
2
),
"utf8"
);
console.log("[4/4] Overlaying markers");
const overlay = makeSvgOverlay(inside, meta.outW, meta.outH, radius);
await sharp(stitchedPath)
.composite([{ input: overlay, top: 0, left: 0 }])
.png({ compressionLevel: 9 })
.toFile(stitchedOutPath);
console.log(` wrote: ${stitchedOutPath}`);
console.log(` wrote: ${jsonOutPath}`);
}
main().catch(err => {
console.error(err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment