diff --git a/bin/atlas-baker.js b/bin/atlas-baker.js index 1d5d4b7..c45a4a9 100644 --- a/bin/atlas-baker.js +++ b/bin/atlas-baker.js @@ -22,6 +22,8 @@ function parseArgs(argv) { opts.blocksSightPattern = new RegExp(next); i++; break; case '--pad-px': opts.padPx = parseInt(next, 10); i++; break; case '--schema': opts.schema = next; i++; break; + case '--mode': opts.mode = next; i++; break; + case '--strip-prefix': opts.stripPrefix = next; i++; break; case '--verbose': opts.verbose = true; break; default: console.error(`unknown arg: ${a}`); @@ -35,10 +37,17 @@ function parseArgs(argv) { try { const opts = parseArgs(process.argv.slice(2)); const result = await bake(opts); - console.log( - `OK ${result.atlasId}: ${result.tileCount} tiles, ` - + `${result.boundsW}x${result.boundsH} px atlas` - ); + if (opts.mode === 'sprite') { + console.log( + `OK ${result.atlasId}: ${result.spriteCount} sprites, ` + + `${result.boundsW}x${result.boundsH} px atlas` + ); + } else { + console.log( + `OK ${result.atlasId}: ${result.tileCount} tiles, ` + + `${result.boundsW}x${result.boundsH} px atlas` + ); + } process.exit(0); } catch (err) { console.error('ERROR:', err.message); diff --git a/src/bake-sprite.js b/src/bake-sprite.js new file mode 100644 index 0000000..a31b721 --- /dev/null +++ b/src/bake-sprite.js @@ -0,0 +1,110 @@ +// src/bake-sprite.js +// Sprite-Mode bake orchestrator. Reads sources, packs shelves, composes +// the diffuse atlas, writes outputs + lock. + +const Jimp = require('jimp'); +const { scanSpriteSourceDir } = require('./scan-sprite'); +const { packShelf } = require('./pack-shelf'); +const { loadSpriteLock, mergeSpriteLock } = require('./lock-sprite'); +const { writeSpriteOutputs } = require('./write-sprite'); + +async function bakeSprite(opts) { + const { + inDir, + outDir, + atlasId, + stripPrefix = '', + maxSize = 4096, + padPx = 1, + lockPath, + verbose, + } = opts; + + if (!atlasId) throw new Error('bake-sprite: --atlas-id is required'); + if (!inDir) throw new Error('bake-sprite: --in is required'); + if (!outDir) throw new Error('bake-sprite: --out is required'); + if (padPx < 0 || !Number.isInteger(padPx)) { + throw new Error(`bake-sprite: --pad-px must be non-negative integer, got ${padPx}`); + } + + const scan = scanSpriteSourceDir(inDir, { stripPrefix }); + if (scan.errors.length > 0) { + const e = new Error(scan.errors[0]); + e.scanErrors = scan.errors; + throw e; + } + if (scan.sources.length === 0) { + throw new Error(`bake-sprite: no sprites found in ${inDir}`); + } + + // Load source images + final padded rect-sizes + const rects = []; + for (const s of scan.sources) { + const img = await Jimp.read(s.sourcePath); + const innerW = img.bitmap.width; + const innerH = img.bitmap.height; + // Padding: add 2*padPx to both dimensions, sprite content sits + // at (x+padPx, y+padPx); UV-JSON exposes (x+padPx, y+padPx, innerW, innerH). + rects.push({ + alias: s.alias, + w: innerW + 2 * padPx, + h: innerH + 2 * padPx, + sourceFile: s.sourceFile, + image: img, + innerW, + innerH, + padPx, + }); + } + + const pack = packShelf(rects, maxSize); + if (pack.error) { + throw new Error(pack.message); + } + + // Build placed entries: x/y point to the INNER sprite origin + // (padded-rect top-left + padPx). The writer composites the image + // at this position; the UV-JSON exposes the same inner rect. + const placed = pack.placed.map(p => ({ + alias: p.alias, + x: p.x + p.padPx, + y: p.y + p.padPx, + w: p.innerW, + h: p.innerH, + sourceFile: p.sourceFile, + image: p.image, + })); + + // Lock-merge + const existingLock = lockPath ? loadSpriteLock(lockPath) : null; + const { merged: lock, removed } = mergeSpriteLock(existingLock, scan.sources); + lock.atlas_id = atlasId; + + if (removed.length > 0) { + for (const r of removed) { + console.error(`WARN bake-sprite: alias "${r}" was in lock but is no longer in input — kept in lock for consumer-template safety; remove manually if intentional`); + } + } + + await writeSpriteOutputs({ + atlasId, + outDir, + placed, + boundsW: pack.boundsW, + boundsH: pack.boundsH, + lock, + }); + + if (verbose) { + console.log(`bake-sprite: ${placed.length} sprites, ${pack.boundsW}x${pack.boundsH} atlas`); + } + + return { + atlasId, + spriteCount: placed.length, + boundsW: pack.boundsW, + boundsH: pack.boundsH, + }; +} + +module.exports = { bakeSprite }; diff --git a/src/bake.js b/src/bake.js index 0164f58..146aea9 100644 --- a/src/bake.js +++ b/src/bake.js @@ -8,6 +8,10 @@ const { probeImageDims, composeAtlas, buildAtlasJson, writeOutputs } = require(' const { validateBlob14Sources, validateBlob14Collision } = require('./schema'); async function bake(opts) { + if (opts.mode === 'sprite') { + const { bakeSprite } = require('./bake-sprite'); + return bakeSprite(opts); + } const { inDir, outDir, diff --git a/tests/bake-sprite-collision.test.js b/tests/bake-sprite-collision.test.js new file mode 100644 index 0000000..b0cbc5a --- /dev/null +++ b/tests/bake-sprite-collision.test.js @@ -0,0 +1,17 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { bake } = require('../src/bake'); + +const COLLISION = path.resolve(__dirname, 'fixtures/sprite-collision'); + +test('bake sprite: collision -> error', async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'col-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + await assert.rejects( + bake({ mode: 'sprite', inDir: COLLISION, outDir: tmp, atlasId: 'col' }), + /alias collision/i, + ); +}); diff --git a/tests/bake-sprite-determinism.test.js b/tests/bake-sprite-determinism.test.js new file mode 100644 index 0000000..775e4e7 --- /dev/null +++ b/tests/bake-sprite-determinism.test.js @@ -0,0 +1,32 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { bake } = require('../src/bake'); + +const FRESH = path.resolve(__dirname, 'fixtures/sprite-fresh'); + +test('bake sprite: re-bake produces byte-identical PNG + UV-JSON', async (t) => { + const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'det-a-')); + const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'det-b-')); + t.after(() => { + fs.rmSync(tmpA, { recursive: true, force: true }); + fs.rmSync(tmpB, { recursive: true, force: true }); + }); + + const opts = { mode: 'sprite', inDir: FRESH, atlasId: 'det', padPx: 0 }; + await bake({ ...opts, outDir: tmpA }); + await bake({ ...opts, outDir: tmpB }); + + const pngA = fs.readFileSync(path.join(tmpA, 'det/sprites.diffuse.atlas.png')); + const pngB = fs.readFileSync(path.join(tmpB, 'det/sprites.diffuse.atlas.png')); + const uvA = fs.readFileSync(path.join(tmpA, 'det/sprites.uv.json'), 'utf8'); + const uvB = fs.readFileSync(path.join(tmpB, 'det/sprites.uv.json'), 'utf8'); + const lockA = fs.readFileSync(path.join(tmpA, 'det/sprites.atlas.lock.json'), 'utf8'); + const lockB = fs.readFileSync(path.join(tmpB, 'det/sprites.atlas.lock.json'), 'utf8'); + + assert.deepStrictEqual(pngA, pngB); + assert.strictEqual(uvA, uvB); + assert.strictEqual(lockA, lockB); +}); diff --git a/tests/bake-sprite-fresh.test.js b/tests/bake-sprite-fresh.test.js new file mode 100644 index 0000000..294bec3 --- /dev/null +++ b/tests/bake-sprite-fresh.test.js @@ -0,0 +1,33 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { bake } = require('../src/bake'); + +const FRESH = path.resolve(__dirname, 'fixtures/sprite-fresh'); + +test('bake sprite: fresh bake of 4 fixture sprites', async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bake-sprite-fresh-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + + const result = await bake({ + mode: 'sprite', + inDir: FRESH, + outDir: tmp, + atlasId: 'test', + padPx: 0, + }); + assert.strictEqual(result.atlasId, 'test'); + assert.strictEqual(result.spriteCount, 4); + + // Outputs exist + assert.ok(fs.existsSync(path.join(tmp, 'test/sprites.diffuse.atlas.png'))); + assert.ok(fs.existsSync(path.join(tmp, 'test/sprites.uv.json'))); + assert.ok(fs.existsSync(path.join(tmp, 'test/sprites.atlas.lock.json'))); + + // UV-JSON has expected aliases + const uv = JSON.parse(fs.readFileSync(path.join(tmp, 'test/sprites.uv.json'), 'utf8')); + const aliases = Object.keys(uv.sprites).sort(); + assert.deepStrictEqual(aliases, ['barrel_large', 'bed1', 'bench1', 'sack']); +});