Created
March 11, 2026 14:45
-
-
Save Kattoor/05776686192f36adf1a4eedfdd6f4543 to your computer and use it in GitHub Desktop.
map-and-unit-stitcher.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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