Compare commits
11 Commits
765e4bf964
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddafcd6486 | ||
|
|
8bc4797145 | ||
|
|
0780f90c58 | ||
|
|
f1447b68aa | ||
|
|
766c03c955 | ||
|
|
d5fb03b8d0 | ||
|
|
33f43a1d8e | ||
|
|
348b226c26 | ||
|
|
61c44b4569 | ||
|
|
50a0a3508f | ||
|
|
775ad58308 |
63
README.md
63
README.md
@@ -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
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -23,23 +27,58 @@ sporel-atlas-baker \
|
||||
[--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
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--in <source-dir>` | Directory of source tile images. |
|
||||
| `--out <atlas-dir>` | Output directory for the baked atlas + sidecars. |
|
||||
| `--atlas-id <stable-id>` | Stable atlas identifier written into the lock. |
|
||||
| `--tile-size <N\|auto>` | Tile edge length in pixels, or `auto` to infer it from the source tiles. |
|
||||
| `--max-size <N>` | Maximum atlas dimension in pixels (default 4096). |
|
||||
| `--pad-px <N>` | Edge-replicated padding (gutter) applied around each tile, in pixels (Rev 4 §13.3). |
|
||||
| `--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. |
|
||||
| `--lock <existing-lock.json>` | Reuse an existing lock to keep tile indices stable. |
|
||||
| `--blocks-sight-pattern <regex>` | Regex matching tile names that block line of sight. |
|
||||
| `--verbose` | Print detailed per-tile diagnostics. |
|
||||
| Flag | Tilemap | Sprite | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `--in <source-dir>` | required | required | Source directory. |
|
||||
| `--out <atlas-dir>` | required | required | Output directory. |
|
||||
| `--atlas-id <id>` | required | required | Stable atlas identifier. |
|
||||
| `--mode {tilemap\|sprite}` | default | toggle | Output topology. |
|
||||
| `--tile-size <N\|auto>` | yes | ignored | Tile edge length. |
|
||||
| `--max-size <N>` | yes | yes | Max atlas dimension (default 4096). |
|
||||
| `--pad-px <N>` | yes (default 2) | yes (default 1) | Per-tile/sprite padding. |
|
||||
| `--schema blob-14` | yes | warn+ignore | Slot validation (E1+E2). |
|
||||
| `--strip-prefix "<s>"` | ignored | yes | Strip leading prefix from sprite filenames before alias normalisation. |
|
||||
| `--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.
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -22,6 +22,10 @@ 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 '--pixels-per-meter':
|
||||
opts.pixelsPerMeter = parseInt(next, 10); i++; break;
|
||||
case '--verbose': opts.verbose = true; break;
|
||||
default:
|
||||
console.error(`unknown arg: ${a}`);
|
||||
@@ -35,10 +39,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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sporel-tool-atlas-baker",
|
||||
"version": "0.2.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.",
|
||||
"version": "0.4.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; v0.3 adds --mode sprite for diffuse-only atlases of irregularly-sized sprites with alias→UV-table sidecar; v0.4 adds --pixels-per-meter <N> to sprite-mode, embedded as atlas_meta.pixels_per_meter in sprites.uv.json (consumed by render-side scale-resolution per project canonical px/m).",
|
||||
"bin": {
|
||||
"sporel-atlas-baker": "bin/atlas-baker.js"
|
||||
},
|
||||
|
||||
30
src/alias.js
Normal file
30
src/alias.js
Normal 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 };
|
||||
116
src/bake-sprite.js
Normal file
116
src/bake-sprite.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// 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,
|
||||
pixelsPerMeter,
|
||||
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}`);
|
||||
}
|
||||
if (pixelsPerMeter !== undefined &&
|
||||
(!Number.isFinite(pixelsPerMeter) || pixelsPerMeter <= 0)) {
|
||||
throw new Error(`bake-sprite: --pixels-per-meter must be positive number, got ${pixelsPerMeter}`);
|
||||
}
|
||||
|
||||
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,
|
||||
pixelsPerMeter,
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
49
src/lock-sprite.js
Normal file
49
src/lock-sprite.js
Normal 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
54
src/pack-shelf.js
Normal 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
48
src/scan-sprite.js
Normal 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 };
|
||||
65
src/write-sprite.js
Normal file
65
src/write-sprite.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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, pixelsPerMeter) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
// Build payload with stable key order; only emit atlas_meta when at
|
||||
// least one meta-field is set, so unannotated bakes stay byte-equal
|
||||
// to the v0.3.0 output (no diff for unchanged callers).
|
||||
const payload = {
|
||||
atlas_id: atlasId,
|
||||
atlas_size: [boundsW, boundsH],
|
||||
};
|
||||
if (pixelsPerMeter !== undefined && pixelsPerMeter !== null) {
|
||||
payload.atlas_meta = { pixels_per_meter: pixelsPerMeter };
|
||||
}
|
||||
payload.sprites = sprites;
|
||||
return JSON.stringify(payload, null, 2) + '\n';
|
||||
}
|
||||
|
||||
async function writeSpriteOutputs(opts) {
|
||||
const { atlasId, outDir, placed, boundsW, boundsH, lock, pixelsPerMeter } = 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, pixelsPerMeter),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(atlasDir, 'sprites.atlas.lock.json'),
|
||||
serializeSpriteLock(lock),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { composeSpriteAtlas, buildUvJson, writeSpriteOutputs };
|
||||
50
tests/alias.test.js
Normal file
50
tests/alias.test.js
Normal 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');
|
||||
});
|
||||
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']);
|
||||
});
|
||||
BIN
tests/fixtures/sprite-collision/BenchTable1.png
vendored
Normal file
BIN
tests/fixtures/sprite-collision/BenchTable1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
BIN
tests/fixtures/sprite-collision/Bench_Table1.png
vendored
Normal file
BIN
tests/fixtures/sprite-collision/Bench_Table1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
BIN
tests/fixtures/sprite-fresh/BarrelLarge.png
vendored
Normal file
BIN
tests/fixtures/sprite-fresh/BarrelLarge.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 B |
BIN
tests/fixtures/sprite-fresh/Bed1.png
vendored
Normal file
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
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
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
80
tests/lock-sprite.test.js
Normal 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
71
tests/pack-shelf.test.js
Normal 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
47
tests/scan-sprite.test.js
Normal 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'));
|
||||
});
|
||||
46
tests/write-sprite.test.js
Normal file
46
tests/write-sprite.test.js
Normal 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')));
|
||||
});
|
||||
Reference in New Issue
Block a user