262 lines
7.8 KiB
TypeScript
262 lines
7.8 KiB
TypeScript
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<string, FrameInfo>
|
|
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<string, FrameInfo>()
|
|
|
|
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<string, unknown>
|
|
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 }
|
|
}
|
|
|