/**
* Обработка растровых спрайтов: кроп по альфе, nearest-масштаб, квантизация
* в заданную палитру, обзорный лист. Палитра — параметр (массив [r,g,b]);
* модуль не знает ни одной конкретной палитры.
*/
import { writeFileSync } from 'node:fs';
import { encodePng } from './png.mjs';
// ---------- кроп / масштаб ----------
/** Габариты непрозрачного содержимого (alpha >= 128). */
function bbox({ width, height, rgba }) {
let x0 = width, y0 = height, x1 = -1, y1 = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (rgba[(y * width + x) * 4 + 3] >= 128) {
if (x < x0) x0 = x;
if (y < y0) y0 = y;
if (x > x1) x1 = x;
if (y > y1) y1 = y;
}
}
}
return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
}
/** Пиксель (x,y) кропнутого изображения. */
function at({ width, rgba }, b, x, y) {
const i = ((b.y + y) * width + b.x + x) * 4;
return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]];
}
/** Кроп по альфе + nearest-масштаб до size×size (вписывание с центровкой). */
export function normalizeToSize(img, size = 16) {
const b = bbox(img);
if (!b) return new Uint8Array(size * size * 4); // пустой спрайт
const k = Math.max(b.w / size, b.h / size);
const dw = Math.max(1, Math.round(b.w / k));
const dh = Math.max(1, Math.round(b.h / k));
const ox = (size - dw) >> 1, oy = (size - dh) >> 1;
const out = new Uint8Array(size * size * 4);
for (let y = 0; y < dh; y++) {
for (let x = 0; x < dw; x++) {
const sx = b.x + Math.min(b.w - 1, Math.floor((x + 0.5) * k));
const sy = b.y + Math.min(b.h - 1, Math.floor((y + 0.5) * k));
const src = at(img, b, sx - b.x, sy - b.y);
const i = ((oy + y) * size + (ox + x)) * 4;
out.set(src, i);
}
}
return out;
}
/**
* Квантизация RGBA в палитру (массив [r,g,b]): полупрозрачные пиксели ->
* прозрачные, остальные — ближайший по RGB цвет палитры.
*/
export function quantize(rgba, pal) {
const out = new Uint8Array(rgba.length);
for (let p = 0; p < rgba.length; p += 4) {
if (rgba[p + 3] < 128) continue;
let best = pal[0], bd = Infinity;
for (const c of pal) {
const d = (rgba[p] - c[0]) ** 2 + (rgba[p + 1] - c[1]) ** 2 + (rgba[p + 2] - c[2]) ** 2;
if (d < bd) { bd = d; best = c; }
}
out[p] = best[0]; out[p + 1] = best[1]; out[p + 2] = best[2]; out[p + 3] = 255;
}
return out;
}
// ---------- обзорный лист ----------
/** Лист PNG: сетка спрайтов (scale — во сколько раз увеличить каждый). */
export function reviewSheet(files, path, { size = 16, scale = 4, cols = 6 } = {}) {
const pad = 6, cell = size * scale;
const rows = Math.ceil(files.length / cols);
const W = cols * (cell + pad) + pad;
const H = rows * (cell + pad) + pad;
const bg = Buffer.alloc(W * H * 4);
for (let p = 0; p < bg.length; p += 4) { bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; }
files.forEach(({ rgba }, i) => {
const cx = pad + (i % cols) * (cell + pad);
const cy = pad + Math.floor(i / cols) * (cell + pad);
for (let y = 0; y < cell; y++) {
for (let x = 0; x < cell; x++) {
const sp = ((y / scale) | 0) * size + ((x / scale) | 0);
const si = sp * 4, di = (cy + y) * W + cx + x;
if (rgba[si + 3] === 0) continue;
bg.set(rgba.subarray(si, si + 4), di * 4);
}
}
});
writeFileSync(path, encodePng(W, H, Buffer.from(bg)));
}