Newer
Older
rpg / packages / engine / tools / imaging.mjs
/**
 * Обработка растровых спрайтов: кроп по альфе, 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;
}

// ---------- фигуры на однотонном фоне (AI-листы) ----------

/** Манхэттен-дистанция RGB до фонового цвета (порог задаёт вызывающий). */
function bgDist(img, i, bg) {
    return Math.abs(img.rgba[i] - bg[0]) + Math.abs(img.rgba[i + 1] - bg[1]) + Math.abs(img.rgba[i + 2] - bg[2]);
}

/**
 * Поиск фигур на однотонном фоне (AI-лист с несколькими фигурами): связные
 * компоненты пикселей, отстоящих от фона дальше `dist`. `bg: 'corner'` — фон
 * берётся из углового пикселя листа; либо явный [r,g,b]. Возвращает bbox'ы
 * фигур, отсортированные сверху-вниз, затем слева-направо (не более maxCount,
 * площадь каждой — не меньше minArea).
 */
export function findFigures(img, { bg = 'corner', dist = 60, maxCount = 4, minArea = 400 } = {}) {
    const bgColor = bg === 'corner'
        ? [img.rgba[0], img.rgba[1], img.rgba[2]]
        : bg;
    const { width: w, height: h } = img;
    const seen = new Uint8Array(w * h);
    const boxes = [];
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            const p = y * w + x;
            if (seen[p] || bgDist(img, p * 4, bgColor) < dist) continue;
            // BFS компоненты с бегущими границами bbox.
            let x0 = x, y0 = y, x1 = x, y1 = y, area = 0;
            const queue = [p];
            seen[p] = 1;
            while (queue.length) {
                const q = queue.pop();
                const qx = q % w, qy = (q / w) | 0;
                area++;
                if (qx < x0) x0 = qx; if (qx > x1) x1 = qx;
                if (qy < y0) y0 = qy; if (qy > y1) y1 = qy;
                for (const [nx, ny] of [[qx - 1, qy], [qx + 1, qy], [qx, qy - 1], [qx, qy + 1]]) {
                    if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
                    const n = ny * w + nx;
                    if (!seen[n] && bgDist(img, n * 4, bgColor) >= dist) {
                        seen[n] = 1;
                        queue.push(n);
                    }
                }
            }
            if (area >= minArea) boxes.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 });
        }
    }
    return boxes
        .sort((a, b) => a.y - b.y || a.x - b.x)
        .slice(0, maxCount);
}

/**
 * Nearest-даунскейл прямоугольника `rect = {x,y,w,h}` изображения до w×h
 * с фоном-прозрачностью: сэмпл по центрам ячеек, пиксели ближе `dist` к фону
 * становятся прозрачными (квантовать результат — шаг вызывающего).
 */
export function resizeRect(img, rect, w, h, { bg = 'corner', dist = 60 } = {}) {
    const bgColor = bg === 'corner' ? [img.rgba[0], img.rgba[1], img.rgba[2]] : bg;
    const out = new Uint8Array(w * h * 4);
    const k = Math.max(rect.w / w, rect.h / h);
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            const sx = rect.x + Math.min(rect.w - 1, Math.floor((x + 0.5) * k));
            const sy = rect.y + Math.min(rect.h - 1, Math.floor((y + 0.5) * k));
            const si = (sy * img.width + sx) * 4;
            if (bgDist(img, si, bgColor) < dist) continue;
            out.set(img.rgba.subarray(si, si + 4), (y * w + x) * 4);
        }
    }
    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)));
}