Newer
Older
rpg / packages / engine / tools / __tests__ / atlas.test.mjs
import { describe, expect, it } from 'vitest';
import { findFigures, resizeRect, quantize } from '../imaging.mjs';
import { buildAtlas } from '../atlas.mjs';
import { encodePng, decodePng } from '../png.mjs';

/** Однотонное изображение w×h цвета [r,g,b] (alpha 255). */
const solid = (w, h, [r, g, b]) => {
    const rgba = new Uint8Array(w * h * 4);
    for (let p = 0; p < rgba.length; p += 4) {
        rgba[p] = r; rgba[p + 1] = g; rgba[p + 2] = b; rgba[p + 3] = 255;
    }
    return { width: w, height: h, rgba };
};

/** Прямоугольник цвета color на фоне bg (внутри — сплошной). */
const withRect = (img, rect, [r, g, b]) => {
    for (let y = rect.y; y < rect.y + rect.h; y++) {
        for (let x = rect.x; x < rect.x + rect.w; x++) {
            const i = (y * img.width + x) * 4;
            img.rgba[i] = r; img.rgba[i + 1] = g; img.rgba[i + 2] = b;
        }
    }
    return img;
};

describe('findFigures', () => {
    it('находит фигуры на фоне и сортирует сверху-вниз/слева-направо', () => {
        const img = withRect(withRect(withRect(
            solid(64, 64, [255, 255, 255]),
            { x: 4, y: 8, w: 10, h: 12 }, [10, 20, 30]),
            { x: 40, y: 4, w: 12, h: 14 }, [40, 50, 60]),
            { x: 20, y: 40, w: 16, h: 16 }, [70, 80, 90]
        );
        expect(findFigures(img, { minArea: 30 })).toEqual([
            { x: 40, y: 4, w: 12, h: 14 }, // y=4 раньше y=8
            { x: 4, y: 8, w: 10, h: 12 },
            { x: 20, y: 40, w: 16, h: 16 }
        ]);
    });

    it('bg: corner берёт фон из углового пикселя, мелочь отсеивается по minArea', () => {
        const img = withRect(solid(32, 32, [240, 240, 240]),
            { x: 2, y: 2, w: 20, h: 20 }, [0, 0, 0]);
        img.rgba[(31 * 32 + 31) * 4] = 0; // одиночный пиксель в углу — шум
        const figs = findFigures(img, { minArea: 100 });
        expect(figs).toEqual([{ x: 2, y: 2, w: 20, h: 20 }]);
    });

    it('явный bg и dist: пиксели ближе порога сливаются с фоном', () => {
        const img = withRect(solid(32, 32, [0, 0, 0]),
            { x: 1, y: 1, w: 10, h: 10 }, [30, 30, 30]);
        // dist 100: |30|*3 = 90 < 100 — фигура не видна
        expect(findFigures(img, { bg: [0, 0, 0], dist: 100, minArea: 10 })).toEqual([]);
        // dist 50: фигура видна
        expect(findFigures(img, { bg: [0, 0, 0], dist: 50, minArea: 10 })).toEqual([
            { x: 1, y: 1, w: 10, h: 10 }
        ]);
    });
});

describe('resizeRect', () => {
    it('nearest-сэмпл по центрам ячеек', () => {
        const img = withRect(solid(8, 8, [255, 255, 255]),
            { x: 2, y: 2, w: 4, h: 4 }, [255, 0, 0]);
        const out = resizeRect(img, { x: 2, y: 2, w: 4, h: 4 }, 2, 2);
        // k = 2, центры (0.5,0.5)*2 = (1,1) — все сэмплы внутри красного
        expect(out[0]).toBe(255); expect(out[1]).toBe(0); expect(out[3]).toBe(255);
    });

    it('фон становится прозрачным (bg corner + dist)', () => {
        const img = withRect(solid(8, 8, [255, 255, 255]),
            { x: 2, y: 2, w: 4, h: 4 }, [255, 0, 0]);
        const out = resizeRect(img, { x: 0, y: 0, w: 8, h: 8 }, 4, 4);
        // k = 2: углы сэмплят фон -> alpha 0; центр (2..3,2..3) — красный
        expect(out[3]).toBe(0);
        const center = (2 * 4 + 2) * 4;
        expect(out[center]).toBe(255); expect(out[center + 1]).toBe(0);
        expect(out[center + 3]).toBe(255);
    });
});

describe('buildAtlas', () => {
    const frame = (r) => {
        const f = new Uint8Array(16 * 24 * 4);
        for (let p = 0; p < f.length; p += 4) f[p] = r, f[p + 3] = 255;
        return f;
    };
    it('кадры в строку + Pixi JSON (формат hero_sheet)', () => {
        const { png, json } = buildAtlas([frame(1), frame(2)], ['a', 'b'], { frameW: 16, frameH: 24, image: 'x.png' });
        const sheet = decodePng(png);
        expect(sheet.width).toBe(32);
        expect(sheet.height).toBe(24);
        expect(sheet.rgba[0]).toBe(1);
        expect(sheet.rgba[(0 * 32 + 16) * 4]).toBe(2); // второй кадр с x=16
        expect(json.frames.a.frame).toEqual({ x: 0, y: 0, w: 16, h: 24 });
        expect(json.frames.b.frame).toEqual({ x: 16, y: 0, w: 16, h: 24 });
        expect(json.meta).toEqual({ image: 'x.png', size: { w: 32, h: 24 }, scale: 1 });
    });
    it('бросает при расхождении длины имён и размере кадра', () => {
        expect(() => buildAtlas([frame(1)], ['a', 'b'], { frameW: 16, frameH: 24, image: 'x.png' })).toThrow();
        expect(() => buildAtlas([new Uint8Array(10)], ['a'], { frameW: 16, frameH: 24, image: 'x.png' })).toThrow();
    });
    it('animations: индексы → списки имён кадров (формат Pixi)', () => {
        const { json } = buildAtlas([frame(1), frame(2), frame(3)], ['a', 'b', 'c'], {
            frameW: 16, frameH: 24, image: 'x.png',
            animations: { walk: [0, 1], idle: [0] }
        });
        expect(json.animations).toEqual({ walk: ['a', 'b'], idle: ['a'] });
        // Без animations ключа — поле отсутствует (обратная совместимость)
        const plain = buildAtlas([frame(1)], ['a'], { frameW: 16, frameH: 24, image: 'x.png' });
        expect('animations' in plain.json).toBe(false);
    });
    it('animations: индекс вне кадров — ошибка', () => {
        expect(() => buildAtlas([frame(1)], ['a'], {
            frameW: 16, frameH: 24, image: 'x.png', animations: { walk: [1] }
        })).toThrow();
    });
});

describe('roundtrip findFigures -> resizeRect -> quantize на PNG', () => {
    it('лист с двумя квадратами -> кадры в палитре', () => {
        const img = withRect(withRect(solid(64, 32, [255, 255, 255]),
            { x: 4, y: 4, w: 20, h: 24 }, [255, 0, 0]),
            { x: 36, y: 4, w: 20, h: 24 }, [0, 0, 255]);
        const figs = findFigures(img, { minArea: 100 });
        expect(figs).toHaveLength(2);
        const first = quantize(resizeRect(img, figs[0], 8, 8), [[255, 0, 0], [0, 0, 255], [255, 255, 255]]);
        const second = quantize(resizeRect(img, figs[1], 8, 8), [[255, 0, 0], [0, 0, 255], [255, 255, 255]]);
        expect(first.length).toBe(8 * 8 * 4);
        // все сэмплы внутри bbox фигуры -> весь кадр в цвете фигуры, без фона
        expect([...first.slice(0, 3)]).toEqual([255, 0, 0]);
        expect([...second.slice(0, 3)]).toEqual([0, 0, 255]);
    });
});