import { deflateSync, inflateSync } from 'node:zlib' import type { SpriteImage } from './image-decoder' // ── Minimal PNG Encoder ──────────────────────────────────────────── const CRC_TABLE = (() => { const table = new Uint32Array(256) for (let n = 0; n < 256; n++) { let c = n for (let k = 0; k < 8; k++) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 } table[n] = c } return table })() function crc32(data: Buffer): number { let crc = 0xffffffff for (let i = 0; i < data.length; i++) { crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8) } return (crc ^ 0xffffffff) >>> 0 } function pngChunk(type: string, data: Buffer): Buffer { const buf = Buffer.alloc(4 + 4 + data.length + 4) buf.writeUInt32BE(data.length, 0) buf.write(type, 4, 4, 'ascii') data.copy(buf, 8) const crc = crc32(buf.subarray(4, 8 + data.length)) buf.writeUInt32BE(crc >>> 0, 8 + data.length) return buf } export function encodePng(width: number, height: number, rgba: Buffer): Buffer { const rawData = Buffer.alloc(height * (1 + width * 4)) for (let y = 0; y < height; y++) { const rowStart = y * (1 + width * 4) rawData[rowStart] = 0 // filter: None rgba.copy(rawData, rowStart + 1, y * width * 4, (y + 1) * width * 4) } const compressed = deflateSync(rawData) const chunks: Buffer[] = [] // Signature chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) // IHDR const ihdr = Buffer.alloc(13) ihdr.writeUInt32BE(width, 0) ihdr.writeUInt32BE(height, 4) ihdr[8] = 8 // bit depth ihdr[9] = 6 // color type: RGBA ihdr[10] = 0 // compression ihdr[11] = 0 // filter ihdr[12] = 0 // interlace chunks.push(pngChunk('IHDR', ihdr)) // IDAT chunks.push(pngChunk('IDAT', compressed)) // IEND chunks.push(pngChunk('IEND', Buffer.alloc(0))) return Buffer.concat(chunks) } // ── Bin-Packing Spritesheet Builder ──────────────────────────────── interface PackedRect { x: number y: number w: number h: number name: string } function nextPow2(v: number): number { v-- v |= v >> 1 v |= v >> 2 v |= v >> 4 v |= v >> 8 v |= v >> 16 return v + 1 } /** * Simple shelf-based bin packing algorithm. * Sorts sprites by height descending, then packs left-to-right in rows. * Produces much tighter spritesheets than simple vertical stacking. */ function binPack(images: SpriteImage[]): { rects: PackedRect[]; width: number; height: number } { if (images.length === 0) { return { rects: [], width: 1, height: 1 } } // Sort by height descending for better shelf packing const sorted = [...images].sort((a, b) => b.height - a.height || b.width - a.width) // Estimate initial atlas width based on total area const totalArea = sorted.reduce((sum, img) => sum + img.width * img.height, 0) const maxSingleWidth = Math.max(...sorted.map((i) => i.width)) let atlasWidth = Math.max(maxSingleWidth, Math.ceil(Math.sqrt(totalArea))) // Round up to power of 2 for GPU friendliness (max 2048) atlasWidth = Math.min(2048, nextPow2(atlasWidth)) const rects: PackedRect[] = [] let shelfY = 0 let shelfHeight = 0 let shelfX = 0 for (const img of sorted) { // If sprite doesn't fit on current shelf, start new shelf if (shelfX + img.width > atlasWidth) { shelfY += shelfHeight shelfHeight = 0 shelfX = 0 } rects.push({ x: shelfX, y: shelfY, w: img.width, h: img.height, name: img.name }) shelfX += img.width shelfHeight = Math.max(shelfHeight, img.height) } const finalHeight = shelfY + shelfHeight return { rects, width: atlasWidth, height: finalHeight || 1 } } // ── Spritesheet Builder ──────────────────────────────────────────── interface FrameInfo { x: number y: number w: number h: number } export function buildSpritesheet(images: SpriteImage[]): { png: Buffer frames: Map width: number height: number } { if (images.length === 0) { const emptyPng = encodePng(1, 1, Buffer.alloc(4)) return { png: emptyPng, frames: new Map(), width: 1, height: 1 } } const { rects, width: sheetWidth, height: sheetHeight } = binPack(images) // Build lookup by name for pixel data const imageByName = new Map(images.map((img) => [img.name, img])) const combined = Buffer.alloc(sheetWidth * sheetHeight * 4) const frames = new Map() for (const rect of rects) { const img = imageByName.get(rect.name) if (!img) continue // Copy pixels row by row for (let y = 0; y < img.height; y++) { const srcStart = y * img.width * 4 const dstStart = ((rect.y + y) * sheetWidth + rect.x) * 4 img.pixels.copy(combined, dstStart, srcStart, srcStart + img.width * 4) } frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h }) } const png = encodePng(sheetWidth, sheetHeight, combined) return { png, frames, width: sheetWidth, height: sheetHeight } } // ── .nitro Bundle Creator ────────────────────────────────────────── export function createNitroBundle(jsonData: object, pngData: Buffer, name: string): Buffer { const jsonStr = JSON.stringify(jsonData) const jsonCompressed = deflateSync(Buffer.from(jsonStr, 'utf-8')) const pngCompressed = deflateSync(pngData) const jsonFileName = `${name}.json` const pngFileName = `${name}.png` const totalSize = 2 + (2 + jsonFileName.length + 4 + jsonCompressed.length) + (2 + pngFileName.length + 4 + pngCompressed.length) const bundle = Buffer.alloc(totalSize) let offset = 0 bundle.writeInt16BE(2, offset) offset += 2 bundle.writeInt16BE(jsonFileName.length, offset) offset += 2 bundle.write(jsonFileName, offset, jsonFileName.length, 'utf-8') offset += jsonFileName.length bundle.writeInt32BE(jsonCompressed.length, offset) offset += 4 jsonCompressed.copy(bundle, offset) offset += jsonCompressed.length bundle.writeInt16BE(pngFileName.length, offset) offset += 2 bundle.write(pngFileName, offset, pngFileName.length, 'utf-8') offset += pngFileName.length bundle.writeInt32BE(pngCompressed.length, offset) offset += 4 pngCompressed.copy(bundle, offset) return bundle } // ── .nitro Bundle Parser ────────────────────────────────────────── export function parseNitroBundle(buffer: Buffer): { json: Record jsonFileName: string png: Buffer pngFileName: string } { let offset = 0 const fileCount = buffer.readInt16BE(offset) offset += 2 if (fileCount < 2) { throw new Error(`Expected at least 2 files in .nitro bundle, got ${fileCount}`) } // File 1: JSON const jsonFileNameLen = buffer.readInt16BE(offset) offset += 2 const jsonFileName = buffer.toString('utf-8', offset, offset + jsonFileNameLen) offset += jsonFileNameLen const jsonCompressedLen = buffer.readInt32BE(offset) offset += 4 const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen) offset += jsonCompressedLen const jsonStr = inflateSync(jsonCompressed).toString('utf-8') const json = JSON.parse(jsonStr) // File 2: PNG const pngFileNameLen = buffer.readInt16BE(offset) offset += 2 const pngFileName = buffer.toString('utf-8', offset, offset + pngFileNameLen) offset += pngFileNameLen const pngCompressedLen = buffer.readInt32BE(offset) offset += 4 const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen) offset += pngCompressedLen const png = inflateSync(pngCompressed) return { json, jsonFileName, png, pngFileName } }