This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -4,88 +4,88 @@ 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;
|
||||
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;
|
||||
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;
|
||||
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 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 compressed = deflateSync(rawData);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
// Signature
|
||||
chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||
// 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));
|
||||
// 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));
|
||||
// IDAT
|
||||
chunks.push(pngChunk("IDAT", compressed));
|
||||
|
||||
// IEND
|
||||
chunks.push(pngChunk("IEND", Buffer.alloc(0)));
|
||||
// IEND
|
||||
chunks.push(pngChunk("IEND", Buffer.alloc(0)));
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
// ── Bin-Packing Spritesheet Builder ────────────────────────────────
|
||||
|
||||
interface PackedRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
name: string;
|
||||
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;
|
||||
v--;
|
||||
v |= v >> 1;
|
||||
v |= v >> 2;
|
||||
v |= v >> 4;
|
||||
v |= v >> 8;
|
||||
v |= v >> 16;
|
||||
return v + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,168 +93,193 @@ function nextPow2(v: number): number {
|
||||
* 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 };
|
||||
}
|
||||
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);
|
||||
// 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)));
|
||||
// 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));
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 };
|
||||
const finalHeight = shelfY + shelfHeight;
|
||||
return { rects, width: atlasWidth, height: finalHeight || 1 };
|
||||
}
|
||||
|
||||
// ── Spritesheet Builder ────────────────────────────────────────────
|
||||
|
||||
interface FrameInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export function buildSpritesheet(images: SpriteImage[]): {
|
||||
png: Buffer;
|
||||
frames: Map<string, FrameInfo>;
|
||||
width: number;
|
||||
height: number;
|
||||
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 };
|
||||
}
|
||||
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);
|
||||
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]));
|
||||
// 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>();
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
// 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 };
|
||||
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);
|
||||
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 jsonFileName = `${name}.json`;
|
||||
const pngFileName = `${name}.png`;
|
||||
|
||||
const totalSize =
|
||||
2 +
|
||||
(2 + jsonFileName.length + 4 + jsonCompressed.length) +
|
||||
(2 + pngFileName.length + 4 + pngCompressed.length);
|
||||
const totalSize =
|
||||
2 +
|
||||
(2 + jsonFileName.length + 4 + jsonCompressed.length) +
|
||||
(2 + pngFileName.length + 4 + pngCompressed.length);
|
||||
|
||||
const bundle = Buffer.alloc(totalSize);
|
||||
let offset = 0;
|
||||
const bundle = Buffer.alloc(totalSize);
|
||||
let offset = 0;
|
||||
|
||||
bundle.writeInt16BE(2, offset);
|
||||
offset += 2;
|
||||
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(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);
|
||||
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;
|
||||
return bundle;
|
||||
}
|
||||
|
||||
// ── .nitro Bundle Parser ──────────────────────────────────────────
|
||||
|
||||
export function parseNitroBundle(buffer: Buffer): {
|
||||
json: Record<string, unknown>;
|
||||
jsonFileName: string;
|
||||
png: Buffer;
|
||||
pngFileName: string;
|
||||
json: Record<string, unknown>;
|
||||
jsonFileName: string;
|
||||
png: Buffer;
|
||||
pngFileName: string;
|
||||
} {
|
||||
let offset = 0;
|
||||
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}`);
|
||||
}
|
||||
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 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);
|
||||
// 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 };
|
||||
return { json, jsonFileName, png, pngFileName };
|
||||
}
|
||||
Reference in new issue
Block a user