diff --git a/src/write.js b/src/write.js new file mode 100644 index 0000000..bc6b1c8 --- /dev/null +++ b/src/write.js @@ -0,0 +1,118 @@ +// src/write.js +// Compose placed tiles into output diffuse + height PNGs, serialize atlas.json. +// Reads multi-format inputs (PNG/WebP/JPG) via jimp; writes deterministic PNGs via pngjs. + +const fs = require('node:fs'); +const path = require('node:path'); +const { PNG } = require('pngjs'); +const Jimp = require('jimp'); + +// Load a diffuse image as a Jimp instance, resized (cover-style) to (w, h) +// if its native dims do not match. Returns { width, height, data } where +// data is a Buffer in RGBA8888 layout matching pngjs.PNG.data shape. +async function loadDiffuseImage(filePath, targetW, targetH) { + const img = await Jimp.read(filePath); + if (img.bitmap.width !== targetW || img.bitmap.height !== targetH) { + img.resize(targetW, targetH, Jimp.RESIZE_BICUBIC); + } + return { + width: img.bitmap.width, + height: img.bitmap.height, + data: Buffer.from(img.bitmap.data), // RGBA8888 already + }; +} + +// Load a height image and convert to L8 layout (in RGBA buffer slot 0 = the L +// value, repeated across G/B for downstream uniformity). Resized to (w, h) +// if mismatched. +async function loadHeightImageOrSynth(filePath, w, h) { + if (filePath && fs.existsSync(filePath)) { + const img = await Jimp.read(filePath); + if (img.bitmap.width !== w || img.bitmap.height !== h) { + img.resize(w, h, Jimp.RESIZE_BICUBIC); + } + // Jimp gives RGBA; collapse to grayscale via R-channel (heightmaps are + // assumed to be encoded uniformly in R or grayscale-promoted). + return { + width: w, + height: h, + data: Buffer.from(img.bitmap.data), + }; + } + // Synth L8=0 plane in RGBA layout + const buf = Buffer.alloc(w * h * 4); + for (let i = 3; i < buf.length; i += 4) buf[i] = 255; // alpha + return { width: w, height: h, data: buf }; +} + +// Returns the dimensions of a source image without fully decoding the pixels. +// Used by the bake orchestrator's auto-tile-size detection (Task 11 extension). +async function probeImageDims(filePath) { + const img = await Jimp.read(filePath); + return { width: img.bitmap.width, height: img.bitmap.height }; +} + +async function composeAtlas(placedTiles, boundsW, boundsH) { + const diffuse = new PNG({ width: boundsW, height: boundsH }); + const height = new PNG({ width: boundsW, height: boundsH }); + diffuse.data.fill(0); + height.data.fill(0); + // Empty pixels are alpha=0 in diffuse (transparent black). Height-empty + // is fully opaque alpha=255 since L8 maps care about R-channel only. + for (let i = 3; i < height.data.length; i += 4) height.data[i] = 255; + + for (const tile of placedTiles) { + const d = await loadDiffuseImage(tile.diffusePath, tile.w, tile.h); + const h = await loadHeightImageOrSynth(tile.heightPath, tile.w, tile.h); + for (let y = 0; y < tile.h; y++) { + for (let x = 0; x < tile.w; x++) { + const srcIdx = (y * tile.w + x) * 4; + const dstIdx = ((tile.y + y) * boundsW + (tile.x + x)) * 4; + diffuse.data[dstIdx + 0] = d.data[srcIdx + 0]; + diffuse.data[dstIdx + 1] = d.data[srcIdx + 1]; + diffuse.data[dstIdx + 2] = d.data[srcIdx + 2]; + diffuse.data[dstIdx + 3] = d.data[srcIdx + 3]; + // Height: take R-channel of source as L, repeat across RGB + height.data[dstIdx + 0] = h.data[srcIdx + 0]; + height.data[dstIdx + 1] = h.data[srcIdx + 0]; + height.data[dstIdx + 2] = h.data[srcIdx + 0]; + height.data[dstIdx + 3] = 255; + } + } + } + return { diffuse, height }; +} + +function buildAtlasJson(atlasId, atlasVersion, tileSize, boundsW, boundsH, placedTiles, blocksSightPattern) { + const tiles = placedTiles + .slice() + .sort((a, b) => a.id - b.id) + .map(t => { + const entry = { + id: t.id, + name: t.name, + uv: [t.x, t.y, t.w, t.h], + }; + if (blocksSightPattern && blocksSightPattern.test(t.name)) { + entry.blocks_sight = true; + } + return entry; + }); + return { + atlas_id: atlasId, + atlas_version: atlasVersion, + atlas_size_px: [boundsW, boundsH], + tile_size_px: tileSize, + tiles, + }; +} + +function writeOutputs(outDir, diffusePng, heightPng, atlasJson, lockJson) { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, 'tiles.diffuse.atlas.png'), PNG.sync.write(diffusePng)); + fs.writeFileSync(path.join(outDir, 'tiles.height.atlas.png'), PNG.sync.write(heightPng)); + fs.writeFileSync(path.join(outDir, 'tiles.atlas.json'), JSON.stringify(atlasJson, null, 2) + '\n'); + fs.writeFileSync(path.join(outDir, 'tiles.atlas.lock.json'), JSON.stringify(lockJson, null, 2) + '\n'); +} + +module.exports = { loadDiffuseImage, loadHeightImageOrSynth, probeImageDims, composeAtlas, buildAtlasJson, writeOutputs }; diff --git a/tests/write.test.js b/tests/write.test.js new file mode 100644 index 0000000..1ec09cb --- /dev/null +++ b/tests/write.test.js @@ -0,0 +1,66 @@ +// tests/write.test.js +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const { PNG } = require('pngjs'); +const { composeAtlas, buildAtlasJson } = require('../src/write'); + +const FIX = path.resolve(__dirname, 'fixtures/small-fresh'); + +test('compose: 3 tiles into 96x32 atlas', async () => { + const placed = [ + { name: 'grass', id: 1, x: 0, y: 0, w: 32, h: 32, diffusePath: path.join(FIX, 'grass_diffuse.png'), heightPath: null }, + { name: 'stone', id: 2, x: 32, y: 0, w: 32, h: 32, diffusePath: path.join(FIX, 'stone_diffuse.png'), heightPath: null }, + { name: 'water', id: 3, x: 64, y: 0, w: 32, h: 32, diffusePath: path.join(FIX, 'water_diffuse.png'), heightPath: path.join(FIX, 'water_height.png') }, + ]; + const { diffuse, height } = await composeAtlas(placed, 128, 32); + assert.strictEqual(diffuse.width, 128); + assert.strictEqual(diffuse.height, 32); + // Sample grass pixel (0,0): expect RGB ~ (110, 170, 80) + const gIdx = 0; + assert.strictEqual(diffuse.data[gIdx + 0], 110); + assert.strictEqual(diffuse.data[gIdx + 1], 170); + assert.strictEqual(diffuse.data[gIdx + 2], 80); + // Sample stone pixel (32, 0): expect RGB ~ (120, 120, 120) + const sIdx = (0 * 128 + 32) * 4; + assert.strictEqual(diffuse.data[sIdx + 0], 120); + // Height should be 0 for synth-tiles (grass, stone) + assert.strictEqual(height.data[0], 0); // grass position + assert.strictEqual(height.data[(0 * 128 + 32) * 4], 0); // stone position +}); + +test('buildAtlasJson: sorted by ID, blocks_sight pattern applied', () => { + const placed = [ + { name: 'grass', id: 1, x: 0, y: 0, w: 32, h: 32 }, + { name: 'stone_wall_brick', id: 2, x: 32, y: 0, w: 32, h: 32 }, + ]; + const json = buildAtlasJson('demo', 1, 32, 64, 32, placed, /^stone_wall_/); + assert.strictEqual(json.atlas_id, 'demo'); + assert.strictEqual(json.tiles.length, 2); + assert.strictEqual(json.tiles[0].id, 1); + assert.strictEqual(json.tiles[0].name, 'grass'); + assert.deepStrictEqual(json.tiles[0].uv, [0, 0, 32, 32]); + assert.strictEqual(json.tiles[0].blocks_sight, undefined); + assert.strictEqual(json.tiles[1].blocks_sight, true); +}); + +test('compose: WebP source loads + resizes', async () => { + // Optional integration check — only runs if FA-Starter fixture is staged. + // The Task 10 baker run is the real-world verification; this test is a + // cheaper inline guard against jimp.read regressions on WebP. + const webpFixture = path.resolve(__dirname, 'fixtures/multi-format/sample_diffuse.webp'); + if (!fs.existsSync(webpFixture)) return; // skip if not staged + + const placed = [{ name: 'sample', id: 1, x: 0, y: 0, w: 64, h: 64, + diffusePath: webpFixture, heightPath: null }]; + const { diffuse } = await composeAtlas(placed, 64, 64); + assert.strictEqual(diffuse.width, 64); + assert.strictEqual(diffuse.height, 64); + // Just confirm it didn't throw and has nonzero alpha somewhere + let hasAlpha = false; + for (let i = 3; i < diffuse.data.length; i += 4) { + if (diffuse.data[i] > 0) { hasAlpha = true; break; } + } + assert.ok(hasAlpha, 'compose produced fully-transparent atlas'); +});