diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index d0df08a..8e7918a 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -11,7 +11,8 @@ |---|---| | `packages/engine/tools/png.mjs` | PNG-кодек без зависимостей: `encodePng`/`decodePng` (RGBA 8 бит) | | `packages/engine/tools/canvas.mjs` | Canvas (таблица цветов — параметр): set/get пикселя, `fillDiamond` (изо-ромб), `fromAscii`, seeded rng | -| `packages/engine/tools/imaging.mjs` | Кроп по альфе, nearest-масштаб, квантизация в палитру, обзорный лист | +| `packages/engine/tools/imaging.mjs` | Кроп по альфе, nearest-масштаб, квантизация в палитру, обзорный лист; `findFigures`/`resizeRect` — фигуры на однотонном фоне | +| `packages/engine/tools/atlas.mjs` | `buildAtlas` — кадры в строку + Pixi Spritesheet JSON | | `apps/game/tools/pixelart/palette.mjs` | Палитра игры из 32 цветов (hex + RGBA) — сценарный слой | | `apps/game/tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `apps/game/assets/` (канвас с палитрой игры) | @@ -67,6 +68,23 @@ Загрузка в игре — `assets.loadAtlas('chars/hero_sheet')`, кадры — `assets.frames(key, 'hero_walk')`. +Сборка атласа из готовых кадров — `buildAtlas(frames, names, { frameW, frameH, image })` +из `@rpg/engine/tools/atlas.mjs` → `{ png, json }` (формат выше). + +## Фигуры на однотонном фоне + +Для пост-обработки AI-листов (несколько фигур на белом фоне) — `imaging.mjs`: + +- `findFigures(img, { bg = 'corner', dist = 60, maxCount = 4, minArea = 400 })` — + bbox'ы связных компонент пикселей, отстоящих от фона (`'corner'` — угловой + пиксель) дальше `dist`; сортировка сверху-вниз/слева-направо. +- `resizeRect(img, rect, w, h, { bg, dist })` — nearest-даунскейл прямоугольника + до w×h; пиксели ближе `dist` к фону становятся прозрачными (квантизация — + отдельный шаг `quantize` с палитрой-параметром). + +Сценарии применения (манифест персонажей, gen/build/promote) — в игре: +`apps/game/tools/aiart/` (см. `docs/plan.md`, работа B). + ## Замена генератора на рисованный арт Движку не важно происхождение текстур. Чтобы перейти на рисованный арт: diff --git a/packages/engine/tools/__tests__/atlas.test.mjs b/packages/engine/tools/__tests__/atlas.test.mjs new file mode 100644 index 0000000..fa3f1bc --- /dev/null +++ b/packages/engine/tools/__tests__/atlas.test.mjs @@ -0,0 +1,119 @@ +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(); + }); +}); + +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]); + }); +}); \ No newline at end of file diff --git a/packages/engine/tools/atlas.mjs b/packages/engine/tools/atlas.mjs new file mode 100644 index 0000000..7cac4ce --- /dev/null +++ b/packages/engine/tools/atlas.mjs @@ -0,0 +1,45 @@ +/** + * Сборка спрайт-атласа (Pixi Spritesheet): кадры в одну строку + JSON-описание. + * Модуль ничего не знает о контенте: кадры — массив Uint8Array w×h×4 (RGBA), + * имена и размеры задаёт вызывающий. + */ +import { encodePng } from './png.mjs'; + +/** + * Кадры в строку атласа шириной frames.length × frameW. + * @param frames массив Uint8Array (frameW×frameH×4, RGBA) + * @param names имена кадров (ключи JSON.frames) + * @param opts { frameW, frameH, image } — image: имя PNG рядом с JSON + * @returns { png: Buffer, json: object } + */ +export function buildAtlas(frames, names, { frameW, frameH, image }) { + if (frames.length !== names.length) { + throw new Error(`кадры и имена разной длины: ${frames.length} != ${names.length}`); + } + const sheetW = frameW * frames.length; + const sheet = new Uint8Array(sheetW * frameH * 4); + const framesJson = {}; + frames.forEach((src, i) => { + if (src.length !== frameW * frameH * 4) { + throw new Error(`кадр ${names[i]}: ожидается ${frameW}×${frameH}×4 байт, пришло ${src.length}`); + } + for (let y = 0; y < frameH; y++) { + for (let x = 0; x < frameW; x++) { + sheet.set(src.subarray((y * frameW + x) * 4, (y * frameW + x) * 4 + 4), ((y * sheetW) + i * frameW + x) * 4); + } + } + framesJson[names[i]] = { + frame: { x: i * frameW, y: 0, w: frameW, h: frameH }, + rotated: false, + trimmed: false, + sourceSize: { w: frameW, h: frameH } + }; + }); + return { + png: encodePng(sheetW, frameH, Buffer.from(sheet)), + json: { + frames: framesJson, + meta: { image, size: { w: sheetW, h: frameH }, scale: 1 } + } + }; +} \ No newline at end of file diff --git a/packages/engine/tools/imaging.mjs b/packages/engine/tools/imaging.mjs index 49185a9..b3f6e1f 100644 --- a/packages/engine/tools/imaging.mjs +++ b/packages/engine/tools/imaging.mjs @@ -69,6 +69,79 @@ 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 — во сколько раз увеличить каждый). */