Compare commits

...

10 Commits

24 changed files with 795 additions and 18 deletions

View File

@@ -7,6 +7,10 @@ validation (E1), a `collision.json` sidecar (E2 validation + pass-through), and
alpha-analysis that sets a per-tile `tile.opaque` flag. Source tiles may be alpha-analysis that sets a per-tile `tile.opaque` flag. Source tiles may be
PNG, WebP, or JPG (decoded via jimp). PNG, WebP, or JPG (decoded via jimp).
**v0.3.0** adds `--mode sprite` for diffuse-only atlases of irregularly-sized
sprites with an alias→UV-table sidecar (`sprites.uv.json`) + stable alias-set
lock for re-bakes. Tilemap-mode remains the default for backward compatibility.
## Usage ## Usage
```bash ```bash
@@ -23,23 +27,58 @@ sporel-atlas-baker \
[--verbose] [--verbose]
``` ```
### Modes
The baker supports two output topologies:
| Mode | Default | Output |
|---|---|---|
| `tilemap` (default) | Paired diffuse + height tiles on a fixed grid (blob-14 + collision sidecar supported) | `<atlas>.diffuse.png` + `<atlas>.height.png` + `<atlas>.atlas.json` + `<atlas>.atlas.lock.json` |
| `sprite` | Diffuse-only, irregularly-sized standalone sprites | `<atlas>/sprites.diffuse.atlas.png` + `<atlas>/sprites.uv.json` + `<atlas>/sprites.atlas.lock.json` |
Select via `--mode {tilemap|sprite}`. tilemap-mode is the default for
backward compatibility with v0.2.0 call-sites.
### Flags ### Flags
| Flag | Meaning | | Flag | Tilemap | Sprite | Meaning |
| --- | --- | | --- | --- | --- | --- |
| `--in <source-dir>` | Directory of source tile images. | | `--in <source-dir>` | required | required | Source directory. |
| `--out <atlas-dir>` | Output directory for the baked atlas + sidecars. | | `--out <atlas-dir>` | required | required | Output directory. |
| `--atlas-id <stable-id>` | Stable atlas identifier written into the lock. | | `--atlas-id <id>` | required | required | Stable atlas identifier. |
| `--tile-size <N\|auto>` | Tile edge length in pixels, or `auto` to infer it from the source tiles. | | `--mode {tilemap\|sprite}` | default | toggle | Output topology. |
| `--max-size <N>` | Maximum atlas dimension in pixels (default 4096). | | `--tile-size <N\|auto>` | yes | ignored | Tile edge length. |
| `--pad-px <N>` | Edge-replicated padding (gutter) applied around each tile, in pixels (Rev 4 §13.3). | | `--max-size <N>` | yes | yes | Max atlas dimension (default 4096). |
| `--schema <blob-14>` | Slot-validation schema for the source set. `blob-14` is the v0.2 14-slot blob schema (E1); required by lib-core.maps v0.5.1. | | `--pad-px <N>` | yes (default 2) | yes (default 1) | Per-tile/sprite padding. |
| `--lock <existing-lock.json>` | Reuse an existing lock to keep tile indices stable. | | `--schema blob-14` | yes | warn+ignore | Slot validation (E1+E2). |
| `--blocks-sight-pattern <regex>` | Regex matching tile names that block line of sight. | | `--strip-prefix "<s>"` | ignored | yes | Strip leading prefix from sprite filenames before alias normalisation. |
| `--verbose` | Print detailed per-tile diagnostics. | | `--lock <path>` | yes | yes | Reuse existing lock for stable IDs. |
| `--blocks-sight-pattern <re>` | yes | ignored | Regex matching tiles that block LOS. |
| `--verbose` | yes | yes | Detailed diagnostics. |
See `sporel-meta/docs/superpowers/specs/2026-05-21-map-multi-layer-design.md` §4 for the format spec. See `sporel-meta/docs/superpowers/specs/2026-05-21-map-multi-layer-design.md` §4 for the format spec.
### Sprite-Mode Output Format
`<out>/<atlas-id>/sprites.uv.json` carries the alias → UV table:
```json
{
"atlas_id": "tcbasics",
"atlas_size": [W, H],
"sprites": {
"bed1": { "x": 0, "y": 0, "w": 256, "h": 128, "source_file": "Bed1.png" },
"bench1": { "x": 256,"y": 0, "w": 192, "h": 96, "source_file": "Bench1.png" }
}
}
```
`sprites.atlas.lock.json` carries the stable alias-set + source-file
mapping; pass it via `--lock` on re-bakes to preserve alias ordering
when the input set grows. Removed aliases surface as `WARN`-lines on
stderr (dropped from the new lock; consumers referencing the dropped
alias will fail at sample-time).
## Development ## Development
```bash ```bash

View File

@@ -22,6 +22,8 @@ function parseArgs(argv) {
opts.blocksSightPattern = new RegExp(next); i++; break; opts.blocksSightPattern = new RegExp(next); i++; break;
case '--pad-px': opts.padPx = parseInt(next, 10); i++; break; case '--pad-px': opts.padPx = parseInt(next, 10); i++; break;
case '--schema': opts.schema = next; 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; case '--verbose': opts.verbose = true; break;
default: default:
console.error(`unknown arg: ${a}`); console.error(`unknown arg: ${a}`);
@@ -35,10 +37,17 @@ function parseArgs(argv) {
try { try {
const opts = parseArgs(process.argv.slice(2)); const opts = parseArgs(process.argv.slice(2));
const result = await bake(opts); const result = await bake(opts);
console.log( if (opts.mode === 'sprite') {
`OK ${result.atlasId}: ${result.tileCount} tiles, ` console.log(
+ `${result.boundsW}x${result.boundsH} px atlas` `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); process.exit(0);
} catch (err) { } catch (err) {
console.error('ERROR:', err.message); console.error('ERROR:', err.message);

View File

@@ -1,7 +1,7 @@
{ {
"name": "sporel-tool-atlas-baker", "name": "sporel-tool-atlas-baker",
"version": "0.2.0", "version": "0.3.0",
"description": "Bake paired diffuse + height PNG atlases for the Sporel map-lib v0.3.0+ format. v0.2: edge-replicated padding (Rev 4 §13.3), --schema blob-14 E1 slot validation, collision.json sidecar E2 validation + pass-through, alpha-analysis opaque flag. Accepts PNG/WebP/JPG via jimp.", "description": "Bake paired diffuse + height PNG atlases for the Sporel map-lib v0.3.0+ format. v0.2: edge-replicated padding (Rev 4 §13.3), --schema blob-14 E1 slot validation, collision.json sidecar E2 validation + pass-through, alpha-analysis opaque flag. Accepts PNG/WebP/JPG via jimp; v0.3 adds --mode sprite for diffuse-only atlases of irregularly-sized sprites with alias→UV-table sidecar",
"bin": { "bin": {
"sporel-atlas-baker": "bin/atlas-baker.js" "sporel-atlas-baker": "bin/atlas-baker.js"
}, },

30
src/alias.js Normal file
View File

@@ -0,0 +1,30 @@
// src/alias.js
// Pure functions for deriving snake_case aliases from sprite filenames.
// No I/O.
const path = require('node:path');
const SUPPORTED_EXT = ['.png', '.webp', '.jpg', '.jpeg'];
function stripPrefix(name, prefix) {
if (!prefix) return name;
if (name.startsWith(prefix)) return name.slice(prefix.length);
return name;
}
// CamelCase -> snake_case. Boundary: lowercase/digit -> uppercase.
function camelToSnakeCase(s) {
return s
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase();
}
function aliasFromFilename(filename, opts = {}) {
const ext = path.extname(filename).toLowerCase();
let base = SUPPORTED_EXT.includes(ext)
? filename.slice(0, -ext.length)
: filename;
if (opts.stripPrefix) base = stripPrefix(base, opts.stripPrefix);
return camelToSnakeCase(base);
}
module.exports = { stripPrefix, camelToSnakeCase, aliasFromFilename };

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 the existing lock but is no longer in the input set — dropped from new lock; any consumer template referencing "${r}" will fail at sample-time`);
}
}
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'); const { validateBlob14Sources, validateBlob14Collision } = require('./schema');
async function bake(opts) { async function bake(opts) {
if (opts.mode === 'sprite') {
const { bakeSprite } = require('./bake-sprite');
return bakeSprite(opts);
}
const { const {
inDir, inDir,
outDir, outDir,

49
src/lock-sprite.js Normal file
View File

@@ -0,0 +1,49 @@
// src/lock-sprite.js
// Sprite-Atlas-Lock: stable alias-set + source-file mapping across
// re-bakes. New aliases append; removed aliases surface in `removed[]`
// so the caller can loud-warn.
const fs = require('node:fs');
function loadSpriteLock(lockPath) {
if (!lockPath || !fs.existsSync(lockPath)) return null;
const raw = fs.readFileSync(lockPath, 'utf8');
return JSON.parse(raw);
}
function mergeSpriteLock(existing, sources) {
const sourceMap = {};
const currentAliases = [];
for (const s of sources) {
sourceMap[s.alias] = s.sourceFile;
currentAliases.push(s.alias);
}
let mergedAliases;
let removed = [];
if (existing) {
const incoming = new Set(currentAliases);
// Preserve existing order; append new
mergedAliases = existing.aliases.filter(a => incoming.has(a));
for (const a of currentAliases) {
if (!existing.aliases.includes(a)) mergedAliases.push(a);
}
removed = existing.aliases.filter(a => !incoming.has(a));
} else {
mergedAliases = currentAliases;
}
return {
merged: {
atlas_id: existing ? existing.atlas_id : null,
version: 1,
aliases: mergedAliases,
sources: sourceMap,
},
removed,
};
}
function serializeSpriteLock(lock) {
return JSON.stringify(lock, null, 2) + '\n';
}
module.exports = { loadSpriteLock, mergeSpriteLock, serializeSpriteLock };

54
src/pack-shelf.js Normal file
View File

@@ -0,0 +1,54 @@
// src/pack-shelf.js
// Shelf-Pack (height-sort desc, left-to-right per row).
// Deterministic: same input order + same maxSize -> same output.
//
// Input: rects = [{ alias, w, h, ...rest }]
// Output: { placed: [{ x, y, w, h, alias, ...rest }], boundsW, boundsH }
// or { error: 'oversize', message: '...' }
function packShelf(rects, maxSize) {
// Sort by height descending; tie-break by alias to keep determinism
const sorted = [...rects].sort((a, b) => {
if (b.h !== a.h) return b.h - a.h;
return a.alias.localeCompare(b.alias);
});
const placed = [];
let cursorX = 0;
let cursorY = 0;
let rowHeight = 0;
let maxX = 0;
for (const r of sorted) {
if (r.w > maxSize || r.h > maxSize) {
return {
error: 'oversize',
message: `pack-shelf: sprite "${r.alias}" (${r.w}x${r.h}) exceeds max-size ${maxSize}`,
};
}
// Row-wrap if this sprite would exceed maxSize in width
if (cursorX + r.w > maxSize) {
cursorY += rowHeight;
cursorX = 0;
rowHeight = 0;
}
// Check total atlas height
if (cursorY + r.h > maxSize) {
return {
error: 'oversize',
message: `pack-shelf: cumulative atlas height exceeds max-size ${maxSize}`,
};
}
placed.push({ ...r, x: cursorX, y: cursorY });
cursorX += r.w;
if (cursorX > maxX) maxX = cursorX;
if (r.h > rowHeight) rowHeight = r.h;
}
return {
placed,
boundsW: maxX,
boundsH: cursorY + rowHeight,
};
}
module.exports = { packShelf };

48
src/scan-sprite.js Normal file
View File

@@ -0,0 +1,48 @@
// src/scan-sprite.js
// Scans a source dir for PNG/WebP/JPG sprite files (no _diffuse/_height
// suffix convention — sprite-mode treats each file as a standalone
// sprite). Normalises filenames to snake_case aliases, detects
// collisions.
const fs = require('node:fs');
const path = require('node:path');
const { aliasFromFilename } = require('./alias');
const SUPPORTED_EXT = ['.png', '.webp', '.jpg', '.jpeg'];
function scanSpriteSourceDir(srcDir, opts = {}) {
if (!fs.existsSync(srcDir)) {
return { sources: [], errors: [`scan-sprite: source dir does not exist: ${srcDir}`] };
}
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
const candidates = [];
for (const e of entries) {
if (!e.isFile()) continue;
const ext = path.extname(e.name).toLowerCase();
if (!SUPPORTED_EXT.includes(ext)) continue;
candidates.push(e.name);
}
candidates.sort();
const aliasToFile = new Map();
const sources = [];
for (const filename of candidates) {
const alias = aliasFromFilename(filename, opts);
if (aliasToFile.has(alias)) {
const prev = aliasToFile.get(alias);
return {
sources: [],
errors: [`scan-sprite: alias collision "${alias}" — both "${prev}" and "${filename}" map to the same alias`],
};
}
aliasToFile.set(alias, filename);
sources.push({
alias,
sourceFile: filename,
sourcePath: path.join(srcDir, filename),
});
}
return { sources, errors: [] };
}
module.exports = { scanSpriteSourceDir };

58
src/write-sprite.js Normal file
View File

@@ -0,0 +1,58 @@
// src/write-sprite.js
// Compose the diffuse atlas image, build the UV-JSON, write all three
// output files into <outDir>/<atlasId>/.
const fs = require('node:fs');
const path = require('node:path');
const Jimp = require('jimp');
const { serializeSpriteLock } = require('./lock-sprite');
async function composeSpriteAtlas(boundsW, boundsH, placed) {
// Jimp 0.22: synchronous (w, h, color) constructor; color is RGBA32.
const atlas = new Jimp(boundsW, boundsH, 0x00000000);
for (const p of placed) {
// Each `p` carries the source image as `p.image` (jimp instance).
// Blit at (p.x, p.y) — the orchestrator has already pre-offset
// these coordinates by padPx, so x/y is the INNER sprite origin.
atlas.composite(p.image, p.x, p.y);
}
return atlas;
}
function buildUvJson(atlasId, boundsW, boundsH, placed) {
const sprites = {};
for (const p of placed) {
sprites[p.alias] = {
x: p.x,
y: p.y,
w: p.w,
h: p.h,
source_file: p.sourceFile,
};
}
return JSON.stringify({
atlas_id: atlasId,
atlas_size: [boundsW, boundsH],
sprites,
}, null, 2) + '\n';
}
async function writeSpriteOutputs(opts) {
const { atlasId, outDir, placed, boundsW, boundsH, lock } = opts;
const atlasDir = path.join(outDir, atlasId);
fs.mkdirSync(atlasDir, { recursive: true });
const atlas = await composeSpriteAtlas(boundsW, boundsH, placed);
await atlas.writeAsync(path.join(atlasDir, 'sprites.diffuse.atlas.png'));
fs.writeFileSync(
path.join(atlasDir, 'sprites.uv.json'),
buildUvJson(atlasId, boundsW, boundsH, placed),
);
fs.writeFileSync(
path.join(atlasDir, 'sprites.atlas.lock.json'),
serializeSpriteLock(lock),
);
}
module.exports = { composeSpriteAtlas, buildUvJson, writeSpriteOutputs };

50
tests/alias.test.js Normal file
View File

@@ -0,0 +1,50 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { stripPrefix, camelToSnakeCase, aliasFromFilename } = require('../src/alias');
test('stripPrefix: strips matching prefix', () => {
assert.strictEqual(stripPrefix('TC_Basics Asset Pack_Bed1', 'TC_Basics Asset Pack_'), 'Bed1');
});
test('stripPrefix: returns unchanged when no match', () => {
assert.strictEqual(stripPrefix('Bed1', 'TC_Basics Asset Pack_'), 'Bed1');
});
test('stripPrefix: empty prefix returns unchanged', () => {
assert.strictEqual(stripPrefix('Bed1', ''), 'Bed1');
});
test('camelToSnakeCase: BenchTable1 -> bench_table1', () => {
assert.strictEqual(camelToSnakeCase('BenchTable1'), 'bench_table1');
});
test('camelToSnakeCase: ArmourStand -> armour_stand', () => {
assert.strictEqual(camelToSnakeCase('ArmourStand'), 'armour_stand');
});
test('camelToSnakeCase: Bed1 -> bed1', () => {
assert.strictEqual(camelToSnakeCase('Bed1'), 'bed1');
});
test('camelToSnakeCase: BarrelLarge -> barrel_large', () => {
assert.strictEqual(camelToSnakeCase('BarrelLarge'), 'barrel_large');
});
test('camelToSnakeCase: already snake_case unchanged', () => {
assert.strictEqual(camelToSnakeCase('bench_table1'), 'bench_table1');
});
test('aliasFromFilename: full pipeline', () => {
const alias = aliasFromFilename('TC_Basics Asset Pack_BenchTable1.png', {
stripPrefix: 'TC_Basics Asset Pack_',
});
assert.strictEqual(alias, 'bench_table1');
});
test('aliasFromFilename: no opts -> bare-name snake_case', () => {
assert.strictEqual(aliasFromFilename('Bed1.png', {}), 'bed1');
});
test('aliasFromFilename: handles .webp extension', () => {
assert.strictEqual(aliasFromFilename('Bed1.webp', {}), 'bed1');
});

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

BIN
tests/fixtures/sprite-fresh/Bed1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

BIN
tests/fixtures/sprite-fresh/Bench1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

BIN
tests/fixtures/sprite-fresh/Sack.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

80
tests/lock-sprite.test.js Normal file
View File

@@ -0,0 +1,80 @@
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 { loadSpriteLock, mergeSpriteLock, serializeSpriteLock } = require('../src/lock-sprite');
test('loadSpriteLock: missing file -> null', () => {
const result = loadSpriteLock('/nonexistent/lock.json');
assert.strictEqual(result, null);
});
test('loadSpriteLock: reads valid lock', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lock-test-'));
const lockPath = path.join(tmp, 'lock.json');
fs.writeFileSync(lockPath, JSON.stringify({
atlas_id: 'tcbasics',
version: 1,
aliases: ['bed1', 'bench1'],
sources: { bed1: 'Bed1.png', bench1: 'Bench1.png' },
}));
const lock = loadSpriteLock(lockPath);
assert.strictEqual(lock.atlas_id, 'tcbasics');
assert.deepStrictEqual(lock.aliases, ['bed1', 'bench1']);
});
test('mergeSpriteLock: no existing lock -> all new', () => {
const sources = [
{ alias: 'bed1', sourceFile: 'Bed1.png' },
{ alias: 'bench1',sourceFile: 'Bench1.png' },
];
const { merged, removed } = mergeSpriteLock(null, sources);
assert.deepStrictEqual(merged.aliases, ['bed1', 'bench1']);
assert.deepStrictEqual(removed, []);
});
test('mergeSpriteLock: existing alias preserved; new appended', () => {
const existing = {
atlas_id: 'tcbasics',
version: 1,
aliases: ['bed1'],
sources: { bed1: 'Bed1.png' },
};
const sources = [
{ alias: 'bed1', sourceFile: 'Bed1.png' },
{ alias: 'bench1', sourceFile: 'Bench1.png' },
];
const { merged, removed } = mergeSpriteLock(existing, sources);
assert.deepStrictEqual(merged.aliases, ['bed1', 'bench1']);
assert.deepStrictEqual(removed, []);
});
test('mergeSpriteLock: removed alias appears in `removed`', () => {
const existing = {
atlas_id: 'tcbasics',
version: 1,
aliases: ['bed1', 'bench1'],
sources: { bed1: 'Bed1.png', bench1: 'Bench1.png' },
};
const sources = [
{ alias: 'bed1', sourceFile: 'Bed1.png' },
];
const { merged, removed } = mergeSpriteLock(existing, sources);
assert.deepStrictEqual(merged.aliases, ['bed1']);
assert.deepStrictEqual(removed, ['bench1']);
});
test('serializeSpriteLock: stable JSON shape', () => {
const lock = {
atlas_id: 'tcbasics',
version: 1,
aliases: ['bed1'],
sources: { bed1: 'Bed1.png' },
};
const json = serializeSpriteLock(lock);
const parsed = JSON.parse(json);
assert.deepStrictEqual(parsed, lock);
// Trailing newline for POSIX-friendliness
assert.ok(json.endsWith('\n'));
});

71
tests/pack-shelf.test.js Normal file
View File

@@ -0,0 +1,71 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { packShelf } = require('../src/pack-shelf');
test('pack-shelf: 3 equal rects fit horizontally', () => {
const rects = [
{ alias: 'a', w: 32, h: 32 },
{ alias: 'b', w: 32, h: 32 },
{ alias: 'c', w: 32, h: 32 },
];
const r = packShelf(rects, 4096);
assert.strictEqual(r.error, undefined);
assert.strictEqual(r.placed.length, 3);
// All in one row at y=0; row height = 32
assert.strictEqual(r.boundsH, 32);
assert.strictEqual(r.boundsW, 96);
assert.strictEqual(r.placed[0].x, 0);
assert.strictEqual(r.placed[0].y, 0);
});
test('pack-shelf: height-sort desc — tall sprite first', () => {
const rects = [
{ alias: 'short', w: 32, h: 16 },
{ alias: 'tall', w: 32, h: 64 },
{ alias: 'mid', w: 32, h: 32 },
];
const r = packShelf(rects, 4096);
// Row 0 contains tall (64) + mid (32) + short (16) — all fit in row height 64
assert.strictEqual(r.boundsH, 64);
// Tall placed at x=0
const tall = r.placed.find(p => p.alias === 'tall');
assert.strictEqual(tall.x, 0);
assert.strictEqual(tall.y, 0);
});
test('pack-shelf: row wrap when max-width exceeded', () => {
const rects = [
{ alias: 'a', w: 60, h: 20 },
{ alias: 'b', w: 60, h: 20 },
];
const r = packShelf(rects, 100);
// a fits at (0,0). b needs to wrap to next row.
const a = r.placed.find(p => p.alias === 'a');
const b = r.placed.find(p => p.alias === 'b');
assert.strictEqual(a.y, 0);
assert.strictEqual(b.y, 20); // shelf height after row 0 = 20
});
test('pack-shelf: oversize beyond max-size -> error', () => {
const rects = [{ alias: 'huge', w: 200, h: 200 }];
const r = packShelf(rects, 100);
assert.strictEqual(r.error, 'oversize');
assert.match(r.message, /max-size/);
});
test('pack-shelf: deterministic across runs', () => {
const rects = [
{ alias: 'a', w: 32, h: 32 },
{ alias: 'b', w: 64, h: 48 },
{ alias: 'c', w: 16, h: 16 },
];
const r1 = packShelf(rects, 4096);
const r2 = packShelf(rects, 4096);
assert.deepStrictEqual(r1.placed, r2.placed);
});
test('pack-shelf: preserves rect aux fields', () => {
const rects = [{ alias: 'a', w: 32, h: 32, sourceFile: 'A.png' }];
const r = packShelf(rects, 4096);
assert.strictEqual(r.placed[0].sourceFile, 'A.png');
});

47
tests/scan-sprite.test.js Normal file
View File

@@ -0,0 +1,47 @@
const { test } = require('node:test');
const assert = require('node:assert');
const path = require('node:path');
const { scanSpriteSourceDir } = require('../src/scan-sprite');
const FRESH = path.resolve(__dirname, 'fixtures/sprite-fresh');
const COLLISION = path.resolve(__dirname, 'fixtures/sprite-collision');
test('scan-sprite: 4 PNGs, alphabetical, no errors', () => {
const { sources, errors } = scanSpriteSourceDir(FRESH, {});
assert.deepStrictEqual(errors, []);
assert.strictEqual(sources.length, 4);
assert.strictEqual(sources[0].alias, 'barrel_large');
assert.strictEqual(sources[1].alias, 'bed1');
assert.strictEqual(sources[2].alias, 'bench1');
assert.strictEqual(sources[3].alias, 'sack');
});
test('scan-sprite: sources carry sourcePath + sourceFile', () => {
const { sources } = scanSpriteSourceDir(FRESH, {});
assert.ok(sources[1].sourcePath.endsWith('Bed1.png'));
assert.strictEqual(sources[1].sourceFile, 'Bed1.png');
});
test('scan-sprite: alias collision -> loud error', () => {
const { sources, errors } = scanSpriteSourceDir(COLLISION, {});
assert.strictEqual(sources.length, 0);
assert.strictEqual(errors.length, 1);
assert.match(errors[0], /alias collision/i);
assert.match(errors[0], /bench_table1/);
});
test('scan-sprite: missing dir -> error', () => {
const { sources, errors } = scanSpriteSourceDir('/nonexistent/path', {});
assert.strictEqual(sources.length, 0);
assert.ok(errors[0].includes('does not exist'));
});
test('scan-sprite: applies stripPrefix option', () => {
const { sources, errors } = scanSpriteSourceDir(FRESH, {
stripPrefix: 'Bed',
});
assert.deepStrictEqual(errors, []);
// Bed1 -> 1, Bench1 not stripped -> bench1, etc.
const aliases = sources.map(s => s.alias).sort();
assert.ok(aliases.includes('1'));
});

View File

@@ -0,0 +1,46 @@
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 Jimp = require('jimp');
const { writeSpriteOutputs, buildUvJson } = require('../src/write-sprite');
test('buildUvJson: emits atlas_id + atlas_size + sprites table', () => {
const placed = [
{ alias: 'bed1', x: 0, y: 0, w: 64, h: 32, sourceFile: 'Bed1.png' },
{ alias: 'sack', x: 64, y: 0, w: 32, h: 32, sourceFile: 'Sack.png' },
];
const json = buildUvJson('tcbasics', 96, 32, placed);
const parsed = JSON.parse(json);
assert.strictEqual(parsed.atlas_id, 'tcbasics');
assert.deepStrictEqual(parsed.atlas_size, [96, 32]);
assert.strictEqual(parsed.sprites.bed1.x, 0);
assert.strictEqual(parsed.sprites.bed1.w, 64);
assert.strictEqual(parsed.sprites.bed1.source_file, 'Bed1.png');
assert.strictEqual(parsed.sprites.sack.x, 64);
});
test('writeSpriteOutputs: produces 3 files', async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'write-sprite-test-'));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
// Build a 2-sprite atlas in memory
const a = new Jimp(32, 32, 0xff0000ff);
const b = new Jimp(32, 32, 0x00ff00ff);
const placed = [
{ alias: 'red', x: 0, y: 0, w: 32, h: 32, sourceFile: 'Red.png', image: a, padPx: 0 },
{ alias: 'green', x: 32, y: 0, w: 32, h: 32, sourceFile: 'Green.png', image: b, padPx: 0 },
];
await writeSpriteOutputs({
atlasId: 'test',
outDir: tmp,
placed,
boundsW: 64,
boundsH: 32,
lock: { atlas_id: 'test', version: 1, aliases: ['red', 'green'], sources: { red: 'Red.png', green: 'Green.png' } },
});
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')));
});