feat(bake-sprite): end-to-end sprite-mode orchestrator + CLI dispatch

This commit is contained in:
Axel Meyer
2026-06-16 21:23:42 +02:00
parent d5fb03b8d0
commit 766c03c955
6 changed files with 209 additions and 4 deletions

View File

@@ -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);

110
src/bake-sprite.js Normal file
View File

@@ -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 };

View File

@@ -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,

View File

@@ -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,
);
});

View File

@@ -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);
});

View File

@@ -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']);
});