feat(bake-sprite): end-to-end sprite-mode orchestrator + CLI dispatch
This commit is contained in:
@@ -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);
|
||||
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
110
src/bake-sprite.js
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
17
tests/bake-sprite-collision.test.js
Normal file
17
tests/bake-sprite-collision.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
32
tests/bake-sprite-determinism.test.js
Normal file
32
tests/bake-sprite-determinism.test.js
Normal 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);
|
||||
});
|
||||
33
tests/bake-sprite-fresh.test.js
Normal file
33
tests/bake-sprite-fresh.test.js
Normal 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']);
|
||||
});
|
||||
Reference in New Issue
Block a user