Newer
Older
rpg / apps / game / tools / pixelart / gen.mjs
/**
 * Генератор пиксель-арта «Пепельных лугов».
 * Запуск: node tools/pixelart/gen.mjs
 * Выход:  apps/game/assets/tiles/*.png, apps/game/assets/chars/*.png
 */
import { mkdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Canvas as EngineCanvas, fillDiamond, fromAscii as fromAsciiRaw, rng } from '@rpg/engine/tools/canvas.mjs';
import { buildAtlas } from '@rpg/engine/tools/atlas.mjs';
import { bobFrame } from '@rpg/engine/tools/frames.mjs';
import { RGBA } from './palette.mjs';
import { SIZES } from './sizes.mjs';

/** Канвас с палитрой игры (ключи цветов — из palette.mjs). */
class Canvas extends EngineCanvas {
    constructor(w, h) { super(w, h, RGBA); }
}

/** ASCII-карта -> спрайт в палитре игры (extra — дополнительные символы). */
const fromAscii = (rows, extra) => fromAsciiRaw(rows, RGBA, extra);

const OUT = fileURLToPath(new URL('../../assets/', import.meta.url));

// ---------- тайлы 32x16 ----------

/** Ромб с зерном пепла/травы. Без обводки граней: смежные тайлы сливаются в поле. */
function tileBase(base, light, dark, seed, specks) {
    const c = new Canvas(SIZES.tile.w, SIZES.tile.h);
    fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, base);
    const r = rng(seed);
    for (let i = 0; i < specks; i++) {
        const x = Math.floor(r() * 32);
        const y = Math.floor(r() * 16);
        if (c.get(x, y)) {
            c.set(x, y, r() < 0.5 ? dark : base);
        }
    }
    return c;
}

function grassTile(seed = 77) {
    const c = tileBase('G1', 'G2', 'G0', 11, 26);
    // Сухие стебли: 1-2 пикселя G2H. seed — вариант текстуры (микс по хэшу тайла).
    const r = rng(seed);
    for (let i = 0; i < 5; i++) {
        const x = 3 + Math.floor(r() * 26);
        const y = 3 + Math.floor(r() * 10);
        if (c.get(x, y)) {
            c.set(x, y, 'G2H');
        }
    }
    return c;
}

function pathTile(seed = 31) {
    const c = tileBase('P4', 'P5', 'P3', 23, 30);
    // Утоптанный камешек и след колеи. seed — вариант текстуры.
    const r = rng(seed);
    for (let i = 0; i < 4; i++) {
        const x = 2 + Math.floor(r() * 28);
        const y = 2 + Math.floor(r() * 12);
        if (c.get(x, y)) c.set(x, y, 'P2');
    }
    return c;
}

/** Вода: кадр 1 и кадр 2 (блики-штрихи W2 сдвинуты на 1px — живая рябь). */
function waterTile(rippleShift = 0) {
    const c = tileBase('W1', 'W2', 'W0', 31, 0);
    // Рябь: короткие горизонтальные штрихи W2 и глубина W0.
    const r = rng(5);
    for (let i = 0; i < 5; i++) {
        const x = 4 + Math.floor(r() * 22);
        const y = 2 + Math.floor(r() * 12);
        if (c.get(x + rippleShift, y)) c.set(x + rippleShift, y, 'W2');
        if (c.get(x + 1 + rippleShift, y)) c.set(x + 1 + rippleShift, y, 'W2');
    }
    for (let i = 0; i < 3; i++) {
        const x = 6 + Math.floor(r() * 20);
        const y = 5 + Math.floor(r() * 9);
        c.set(x, y, 'W0');
        c.set(x + 1, y, 'W0');
    }
    return c;
}

function ashTile(seed = 9) {
    // Пепельный бугор: чуть светлее травы, тоже без обводки.
    const c = new Canvas(SIZES.tile.w, SIZES.tile.h);
    fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'P3');
    const r = rng(seed);
    for (let i = 0; i < 6; i++) {
        const x = 3 + Math.floor(r() * 26);
        const y = 4 + Math.floor(r() * 9);
        if (c.get(x, y)) c.set(x, y, 'P5');
    }
    return c;
}

function bellflowerTile() {
    const c = grassTile();
    // Три цветка: золото 3px + хайлайт. Тёплый цвет — только жизнь.
    const flowers = [
        [10, 7],
        [21, 9],
        [16, 5]
    ];
    for (const [x, y] of flowers) {
        c.set(x, y - 1, 'B2');
        c.set(x - 1, y, 'B1');
        c.set(x, y, 'B1');
        c.set(x + 1, y, 'B1');
        c.set(x, y + 1, 'G2H'); // стебель
    }
    return c;
}

/** Дерево Ржавой рощи 32x48: ржавый ствол, редкая серо-оливковая крона, кабель. */
function treeTile() {
    const c = new Canvas(...SIZES.tall.tree);
    const r = rng(2026);

    const ellipse = (cx, cy, rx, ry, color) => {
        for (let y = -ry; y <= ry; y++) {
            for (let x = -rx; x <= rx; x++) {
                if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) {
                    c.set(cx + x, cy + y, color);
                }
            }
        }
    };

    // Крона: три перекрывающихся «облака» мёртвой листвы.
    ellipse(16, 9, 10, 7, 'G0');
    ellipse(9, 14, 6, 5, 'G0');
    ellipse(23, 13, 7, 5, 'G0');
    // Внутренний тон и редкие «живые» пряди.
    ellipse(15, 10, 7, 4, 'G1');
    ellipse(10, 14, 3, 2, 'G1');
    ellipse(21, 12, 4, 2, 'G1');
    for (let i = 0; i < 26; i++) {
        const x = 6 + Math.floor(r() * 20);
        const y = 3 + Math.floor(r() * 12);
        if (c.get(x, y) && r() < 0.5) c.set(x, y, 'G2');
    }
    // Прорехи (мёртвое дерево): прозрачные проколы в кроне.
    for (const [x, y] of [
        [13, 6],
        [19, 12],
        [8, 13],
        [22, 8]
    ]) {
        c.set(x, y, null);
        c.set(x + 1, y, null);
    }

    // Ствол: металл с ржавчиной внизу, лёгкий изгиб, корни в стороны.
    for (let y = 18; y <= 47; y++) {
        const sway = Math.round(Math.sin((y - 18) * 0.09) * 1.5);
        const half = y > 40 ? 2 : 1;
        for (let dx = -half; dx <= half; dx++) {
            c.set(16 + sway + dx, y, y > 38 ? 'R1' : 'M1');
        }
        c.set(16 + sway - half - 1, y, y > 38 ? 'R0' : 'M0');
    }
    // Корни.
    for (let i = 0; i < 4; i++) {
        c.set(12 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0');
        c.set(20 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0');
    }
    // Пепел на корнях.
    c.set(13, 46, 'P5');
    c.set(19, 46, 'P5');

    // Кабель через крону и вокруг ствола.
    for (let x = 4; x <= 27; x++) {
        const y = 16 - Math.round(Math.sin((x - 4) * 0.2) * 1);
        if (!c.get(x, y)) c.set(x, y, 'M2');
        else c.set(x, y, 'M2');
    }
    for (let y = 22; y <= 44; y += 7) {
        c.set(14, y, 'M0');
        c.set(19, y, 'M0');
    }

    return c;
}

/** Дом Звенца 32x56: стены из вагонных щитов, крыша из платформ. Tall-объект 1.75 юнита. */
function houseTile() {
    const c = new Canvas(...SIZES.tall.house);
    const r = rng(777);

    // Стены: вагонные щиты — горизонтальные доски C1 со швами C0.
    for (let y = 17; y <= 48; y++) {
        const seam = (y - 17) % 6 === 0;
        for (let x = 6; x <= 25; x++) {
            c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0');
        }
    }
    // Вертикальные стойки по краям стен.
    for (let y = 17; y <= 48; y++) {
        c.set(6, y, 'M1');
        c.set(25, y, 'M1');
    }

    // Крыша-платформа: треугольник из шпал, расширяется книзу.
    for (let i = 0; i <= 15; i++) {
        const y = 1 + i;
        const half = Math.min(13, 1 + Math.floor(i * 0.85));
        for (let dx = -half; dx <= half; dx++) {
            c.set(16 + dx, y, i % 4 === 0 ? 'T0' : 'T1');
        }
    }
    // Свес крыши.
    for (let x = 2; x <= 29; x++) {
        c.set(x, 18, 'T0');
    }

    // Дверь: тёмный проём с бронзовой ручкой.
    for (let y = 32; y <= 48; y++) {
        for (let x = 13; x <= 19; x++) c.set(x, y, 'P1');
    }
    c.set(18, 40, 'B0');

    // Окно: тёмный квадрат с металлической рамой.
    for (let y = 22; y <= 27; y++) {
        for (let x = 9; x <= 13; x++) c.set(x, y, 'P2');
    }
    for (let x = 8; x <= 14; x++) {
        c.set(x, 21, 'M0');
        c.set(x, 28, 'M0');
    }
    for (let y = 21; y <= 28; y++) {
        c.set(7, y, 'M0');
        c.set(15, y, 'M0');
    }
    c.set(11, 24, 'M1'); // переплёт

    // Фундамент из шпал.
    for (let x = 4; x <= 27; x++) {
        c.set(x, 49, 'R1');
        c.set(x, 50, 'R0');
    }

    // Пепел у стен.
    c.set(3, 52, 'P5');
    c.set(28, 52, 'P5');
    c.set(9, 53, 'P4');

    return c;
}

/**
 * Большой дом 64x80 (проба работы C): двухмодульный дом на footprint 2×2,
 * композит из повторяемых частей — стены-щиты + стойки, крыша из шпал,
 * фундамент из рельсов. Якорь — низ-центр footprint'а (ставит движок, prop).
 */
function bigHouseTile() {
    const c = new Canvas(...SIZES.prop.houseBig);
    const r = rng(4242);
    const planks = (x0, x1, y0, y1) => {
        for (let y = y0; y <= y1; y++) {
            const seam = (y - y0) % 6 === 0;
            for (let x = x0; x <= x1; x++) c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0');
        }
    };
    const windowAt = (x0, y0) => {
        for (let y = y0; y <= y0 + 7; y++) for (let x = x0; x <= x0 + 6; x++) c.set(x, y, 'P2');
        for (let x = x0 - 1; x <= x0 + 7; x++) { c.set(x, y0 - 1, 'M0'); c.set(x, y0 + 8, 'M0'); }
        for (let y = y0 - 1; y <= y0 + 8; y++) { c.set(x0 - 1, y, 'M0'); c.set(x0 + 7, y, 'M0'); }
        for (let x = x0; x <= x0 + 6; x++) c.set(x, y0 + 3, 'M1'); // переплёт
        c.set(x0 + 3, y0, 'P5'); // пепел на раме
    };

    // Стены: два модуля щитов, разделены и обрамлены стойками.
    planks(5, 58, 31, 74);
    for (const x of [5, 31, 32, 58]) for (let y = 30; y <= 74; y++) c.set(x, y, 'M1');
    for (let x = 4; x <= 59; x++) c.set(x, 30, 'M0'); // верхний венец

    // Крыша: треугольник шпал, расширяется книзу, свес по краям.
    for (let i = 0; i <= 27; i++) {
        const y = 2 + i;
        const half = Math.min(28, 2 + Math.floor(i * 0.95));
        for (let dx = -half; dx <= half; dx++) c.set(32 + dx, y, i % 4 === 0 ? 'T0' : 'T1');
    }
    for (let x = 2; x <= 61; x++) c.set(x, 29, 'T0');

    // Дверь правого модуля + козырёк.
    for (let y = 56; y <= 74; y++) for (let x = 44; x <= 52; x++) c.set(x, y, 'P1');
    c.set(50, 65, 'B0');
    for (let i = 0; i <= 5; i++) for (let dx = -i; dx <= i + 7; dx++) c.set(48 + dx, 50 + i, i === 5 ? 'T0' : 'T1');

    // Окна: по одному на модуль.
    windowAt(10, 38);
    windowAt(37, 38);

    // Фундамент из рельсов, пепел у стен.
    for (let x = 3; x <= 60; x++) { c.set(x, 75, 'R1'); c.set(x, 76, 'R0'); }
    c.set(2, 77, 'P5'); c.set(61, 77, 'P5'); c.set(8, 78, 'P4'); c.set(55, 78, 'P4');

    return c;
}

/** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */
/** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */
function floorTile() {
    const c = new Canvas(SIZES.tile.w, SIZES.tile.h);
    fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'T1');
    const r = rng(808);
    for (let y = 0; y < 16; y++) {
        for (let x = 0; x < 32; x++) {
            if (!c.get(x, y)) continue;
            // Доски «полосами» по экранному Y + случайная щепка.
            const seam = y % 5 === 0;
            c.set(x, y, seam ? 'T0' : r() < 0.12 ? 'T0' : 'T1');
        }
    }
    return c;
}

/** Каменная стена вагонного цеха: блоки C1 со швами C0, тень по низу. */
function wallTile() {
    const c = new Canvas(...SIZES.tall.wall);
    const r = rng(909);
    for (let y = 0; y < 32; y++) {
        for (let x = 0; x < 32; x++) {
            const seam = y % 8 === 0 || (x + Math.floor(y / 8) * 8) % 16 === 0;
            c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0');
        }
    }
    // Нижний ряд темнее (тень у земли).
    for (let x = 0; x < 32; x++) {
        c.set(x, 30, 'C0');
        c.set(x, 31, 'C0');
    }
    return c;
}

/** Старый телеграфный колодец: каменное жерло, бронзовый обруч — звон = жизнь. */
function wellTile() {
    const c = new Canvas(...SIZES.tall.well);
    const r = rng(1010);

    // Тёмное жерло (вода) — верхняя часть, видна внутри кольца.
    for (let y = 20; y <= 26; y++) {
        const half = Math.floor((26 - y) * 1.8) + 2;
        for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'W0');
    }
    // Каменная кладка вокруг жерла (эллипс).
    for (let y = 16; y <= 28; y++) {
        const half = Math.floor((24 - y) * 1.35) + 3;
        for (let dx = -half; dx <= half; dx++) {
            const rim = Math.abs(dx) > half - 2;
            c.set(16 + dx, y, rim ? (r() < 0.8 ? 'C1' : 'C0') : null === c.get(16 + dx, y) ? 'C0' : c.get(16 + dx, y));
        }
    }
    // Бронзовый обруч по кромке — тёплый: колодец «звонный».
    for (let x = 6; x <= 25; x++) {
        c.set(x, 15, x % 4 === 0 ? 'B0' : 'B1');
        c.set(x, 16, 'B0');
    }
    // Ворото с бронзовой рукоятью.
    for (let y = 4; y <= 15; y++) {
        c.set(7, y, 'T1');
        c.set(24, y, 'T1');
    }
    for (let x = 7; x <= 24; x++) c.set(x, 5, 'T1');
    c.set(12, 4, 'B1');
    c.set(13, 4, 'B2'); // блик бронзы
    return c;
}

function towerTile() {
    const c = new Canvas(...SIZES.tall.tower);
    const r = rng(1414);

    // Бак: цилиндр из полос металла.
    for (let y = 12; y <= 40; y++) {
        for (let x = 7; x <= 24; x++) {
            c.set(x, y, r() < 0.8 ? 'M1' : 'M0');
        }
        // Боковые тени.
        c.set(7, y, 'M0');
        c.set(8, y, 'M0');
        c.set(24, y, 'M0');
    }
    // Пояса обруча.
    for (const y of [14, 22, 30, 38]) {
        for (let x = 7; x <= 24; x++) c.set(x, y, 'M2');
    }
    // Коническая крыша бака.
    for (let i = 0; i <= 6; i++) {
        const half = 9 - i;
        for (let dx = -half; dx <= half; dx++) c.set(16 + dx, 11 - i, 'M2');
    }
    // Флюгер-шпиль.
    for (let y = 2; y <= 4; y++) c.set(16, y, 'M2');
    c.set(18, 2, 'B1'); // бронзовый флажок — колокол помнит голос

    // Ноги-рельсы.
    for (const [lx, rx] of [
        [10, 12],
        [19, 21]
    ]) {
        for (let y = 41; y <= 77; y++) {
            c.set(lx, y, 'M2');
            c.set(lx + 1, y, 'M0');
            c.set(rx, y, 'M2');
            c.set(rx - 1, y, 'M0');
        }
    }
    // Раскосы между ногами.
    for (let i = 0; i < 4; i++) {
        const y = 46 + i * 8;
        for (let dx = 0; dx < 8; dx++) {
            c.set(12 + dx, y + dx, i % 2 === 0 ? 'M1' : 'M2');
            c.set(19 - dx, y + dx, i % 2 === 0 ? 'M1' : 'M2');
        }
    }

    // Колокол из рельсов под баком, между ног.
    for (let y = 44; y <= 54; y++) {
        const half = y < 50 ? 2 : 3;
        for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'B0');
    }
    c.set(16, 45, 'B2'); // блик
    c.set(15, 48, 'B1');
    c.set(16, 55, 'M0'); // язык

    // Пепел у ног.
    c.set(8, 79, 'P5');
    c.set(23, 79, 'P5');

    return c;
}

// ---------- персонажи 16x24 ----------

// Общие цвета: o — контур/глаза (P0), c/C — плащ тень/свет,
// t/T — шляпа тень/свет, s/S — кожа тень/свет, b/B — бронза, p — сапоги (P1).
const HERO_MAP = { o: 'P0', c: 'C0', C: 'C1', t: 'T0', T: 'T1', s: 'S0', S: 'S1', b: 'B0', B: 'B1', p: 'P1', P: 'P2' };

const heroDown1 = fromAscii([
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '..oTttttttttTo..',
    '..oooooooooooo..',
    '....oSSSSSSo....',
    '....oSoSSoSo....',
    '....oSSSsSSo....',
    '.....oSSSSo.....',
    '....oCCCCCCo....',
    '...oCCCCCCCCo...',
    '..oCCCCCCCCCCo..',
    '..oCCCCCCCCCCo..',
    '..obbBBBBBBbbo..',
    '..oCCCCCCCCCCo..',
    '..oCCCCCCCCCCo..',
    '...oCCo..oCCo...',
    '....oPo..oPo....',
    '....oPo..oPo....',
    '...oPPo..oPPo...',
    '...oPPo..oPPo...',
    '....oo....oo....',
    '................'
], HERO_MAP);

// Второй кадр ходьбы: ноги сошлись (passing), туловище на пиксель ниже — боб походки.
const heroDown2 = fromAscii([
    '................',
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '..oTttttttttTo..',
    '..oooooooooooo..',
    '....oSSSSSSo....',
    '....oSoSSoSo....',
    '....oSSSsSSo....',
    '.....oSSSSo.....',
    '....oCCCCCCo....',
    '...oCCCCCCCCo...',
    '..oCCCCCCCCCCo..',
    '..oCCCCCCCCCCo..',
    '..obbBBBBBBbbo..',
    '..oCCCCCCCCCCo..',
    '..oCCCCCCCCCCo..',
    '....oCCoCCo.....',
    '....oPo.oPo.....',
    '....oPo.oPo.....',
    '....oPPoPPo.....',
    '.....oPPPo......',
    '......ooo.......'
], HERO_MAP);

// Спина: швы капюшона под шляпой, лампа на поясе (единственный тёплый пиксель).
const heroUpRows = [
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '..oTttttttttTo..',
    '..oooooooooooo..',
    '.....oTttTo.....',
    '....oCCCCCCo....',
    '...oCCCCCCCCo...',
    '..oCCCCCCCCCCo..',
    '..oCCCCCCCCCCo..',
    '..oCcCCCCCCcCo..',
    '..oCCCCCCCCCCo..',
    '..oCCCCCCCCCCo..',
    '..obbBBffBBbbo..',
    '..oCCCCCCCCCCo..',
    '..oCCCCCCCCCCo..',
    '...oCCo..oCCo...',
    '....oPo..oPo....',
    '....oPo..oPo....',
    '...oPPo..oPPo...',
    '...oPPo..oPPo...',
    '....oo....oo....',
    '................'
];

const heroUp1 = fromAscii(heroUpRows, { ...HERO_MAP, f: 'F0' });

// Второй кадр спины: туловище на пиксель ниже — боб походки.
const heroUp2 = fromAscii(['................', ...heroUpRows.slice(0, -1)], { ...HERO_MAP, f: 'F0' });

const heroSide1 = fromAscii([
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '..oTttttttttTo..',
    '..oooooooooooo..',
    '.....oSSSSSSo...',
    '.....oSSoSSo....',
    '.....oSSSSSs....',
    '......oSSSSo....',
    '.....oCCCCCo....',
    '....oCCCCCCCo...',
    '...oCCCCCCCCCo..',
    '...oCCCCCCCCCo..',
    '...obbBBBBBBbo..',
    '...oCCCCCCCCCo..',
    '...oCCCCCCCCCo..',
    '....oCCoCCo.....',
    '....oPo.oPo.....',
    '....oPo.oPo.....',
    '...oPPo.oPPo....',
    '...oPPo.oPPo....',
    '....oo...oo.....',
    '................'
], HERO_MAP);

// Шаг в бок: ноги шире (stride), туловище на пиксель ниже — боб походки.
const heroSide2 = fromAscii([
    '................',
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '..oTttttttttTo..',
    '..oooooooooooo..',
    '.....oSSSSSSo...',
    '.....oSSoSSo....',
    '.....oSSSSSs....',
    '......oSSSSo....',
    '.....oCCCCCo....',
    '....oCCCCCCCo...',
    '...oCCCCCCCCCo..',
    '...oCCCCCCCCCo..',
    '...obbBBBBBBbo..',
    '...oCCCCCCCCCo..',
    '...oCCCCCCCCCo..',
    '....oCCoCCo.....',
    '...oPo...oPo....',
    '...oPo...oPo....',
    '..oPPo...oPPo...',
    '..oPPo...oPPo...',
    '...oo.....oo....'
], HERO_MAP);

const elderIrwin = fromAscii([
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '...otttttttto...',
    '....ooSSSSoo....',
    '....oSoPSoSo....',
    '....oSsSsSso....',
    '.....oSSSSo.....',
    '.....oTTTTo.....',
    '....oTssssTo....',
    '...oTTsssSTo....',
    '...oTtTssTtTo...',
    '...oTttTTttTo...',
    '...oTttttttTo...',
    '...oTttttttTo...',
    '...oTttttttTo...',
    '...oTttttttTo...',
    '....oTtTTtTo....',
    '....oTtttTo.....',
    '....oTttttTo....',
    '....oTttttTo....',
    '....oTttttTo....',
    '....oooooooo....'
], HERO_MAP);

const traderMila = fromAscii([
    '................',
    '.....oooooo.....',
    '....oWWWWWWo....',
    '...oWwWWWwWo....',
    '...oWWWWWWWo....',
    '....oWSSSSo.....',
    '....oSoPSoSo....',
    '....oSsSsSso....',
    '.....oSSSSo.....',
    '.....oWWWWo.....',
    '....oWwWWwWo....',
    '...oWWWWWWWWo...',
    '...oWWWWWWWWo...',
    '...oWWWBWWWWo...',
    '...oWWWWWWWWo...',
    '....oWWWWWWo....',
    '....oWooWoo.....',
    '....oPo..oPo....',
    '....oPo..oPo....',
    '...oPPo..oPPo...',
    '...oPPo..oPPo...',
    '....oo....oo....',
    '................',
    '................'
], { o: 'P0', W: 'W3', w: 'S0', S: 'S1', s: 'S0', P: 'P0', B: 'B1' });

// ---------- вывод ----------

mkdirSync(OUT + 'tiles', { recursive: true });
mkdirSync(OUT + 'chars', { recursive: true });

function save(canvas, rel) {
    writeFileSync(OUT + rel, canvas.toPng());
    console.log(`  ${rel}`);
}

console.log('Генерация тайлов:');
save(grassTile(), 'tiles/grass.png');
save(grassTile(83), 'tiles/grass_2.png');
save(grassTile(149), 'tiles/grass_3.png');
save(pathTile(), 'tiles/path.png');
save(pathTile(57), 'tiles/path_2.png');
save(waterTile(0), 'tiles/water_1.png');
save(waterTile(1), 'tiles/water_2.png');
save(ashTile(), 'tiles/ash.png');
save(ashTile(41), 'tiles/ash_2.png');
save(bellflowerTile(), 'tiles/bellflower.png');
save(treeTile(), 'tiles/tree.png');
save(houseTile(), 'tiles/house.png');
save(bigHouseTile(), 'tiles/house_big.png');
save(towerTile(), 'tiles/tower.png');
save(floorTile(), 'tiles/floor.png');
save(wallTile(), 'tiles/wall.png');
save(wellTile(), 'tiles/well.png');

console.log('Генерация персонажей:');
save(heroDown1, 'chars/hero_down_1.png');
save(heroDown2, 'chars/hero_down_2.png');
save(heroUp1, 'chars/hero_up_1.png');
save(heroUp2, 'chars/hero_up_2.png');
save(heroSide1, 'chars/hero_side_1.png');
save(heroSide2, 'chars/hero_side_2.png');
save(elderIrwin, 'chars/elder_irwin.png');
save(traderMila, 'chars/trader_mila.png');

// ---------- безгласный олень (пейзажная фауна) ----------
// Выцветший: серо-оливковые G*/M2, никаких тёплых пикселей — голос забран.

const DEER_MAP = { o: 'P0', m: 'M2', G: 'G1', l: 'G2', d: 'P0' };

// Кадр 1: стоит (ноги вместе).
const deerStand = fromAscii([
    '................',
    '................',
    '...o.....o......',
    '....o...o.......',
    '.....o.o........',
    '......omo.......',
    '.....ommmo......',
    '....oGGGGo......',
    '...oGGdGGo......',
    '....oGGGo.......',
    '.....oGo........',
    '..oGGGGGGGGo....',
    '.oGGGGGGGGGGo...',
    '.oGllGGGlllGo...',
    '.oGllGGGlllGo...',
    '..ooGGGGGGoo....',
    '...oGo..oGo.....',
    '...oGo..oGo.....',
    '...oGo..oGo.....',
    '...oGo..oGo.....',
    '...ooo..ooo.....',
    '................',
    '................',
    '................'
], DEER_MAP);

// Кадр 2: шаг (ноги врозь).
const deerWalk = fromAscii([
    '................',
    '................',
    '...o.....o......',
    '....o...o.......',
    '.....o.o........',
    '......omo.......',
    '.....ommmo......',
    '....oGGGGo......',
    '...oGGdGGo......',
    '....oGGGo.......',
    '.....oGo........',
    '..oGGGGGGGGo....',
    '.oGGGGGGGGGGo...',
    '.oGllGGGlllGo...',
    '.oGllGGGlllGo...',
    '..ooGGGGGGoo....',
    '..oGo....oGo....',
    '..oGo....oGo....',
    '.oGo......oGo...',
    '.oGo......oGo...',
    '.ooo......ooo...',
    '................',
    '................',
    '................'
], DEER_MAP);

save(deerStand, 'chars/fauna_deer_1.png');
save(deerWalk, 'chars/fauna_deer_2.png');

// атлас фауны (кадры — та же сетка, что у героя)
const FRAME_W_FAUNA = SIZES.fauna.w;
const FRAME_H_FAUNA = SIZES.fauna.h;
const faunaSheet = buildAtlas(
    [deerStand, deerWalk].map((c) => new Uint8Array(c.data)),
    ['fauna_deer_1', 'fauna_deer_2'],
    {
        frameW: FRAME_W_FAUNA, frameH: FRAME_H_FAUNA, image: 'fauna_sheet.png',
        animations: { fauna_deer_walk: [0, 1] }
    }
);
writeFileSync(OUT + 'chars/fauna_sheet.png', faunaSheet.png);
writeFileSync(OUT + 'chars/fauna_sheet.json', JSON.stringify(faunaSheet.json, null, 2));
console.log(`  chars/fauna_sheet.png (+ .json): ${FRAME_W_FAUNA * 2}x${FRAME_H_FAUNA}`);

// ---------- пепельные сгустки (враги демо-боя) ----------
// Серые P*/G* тела; тёплое ядро F* — «жизнь» загорается, когда сгусток просыпается.

const CLUMP_MAP = { o: 'P0', d: 'P2', p: 'P3', q: 'P4', h: 'P5H', f: 'F0', F: 'F1' };

// Ползун 14x12: плоский слизень на коротких ножках.
const crawler1 = fromAscii([
    '..............',
    '..............',
    '...oooooo.....',
    '..odppppdo....',
    '.odpppppppdo..',
    '.dpppffppppdo.',
    '.dpppppppppdo.',
    '.odpppppppdo..',
    '..oddpppddo...',
    '..o.o....o.o..',
    '..o.o....o.o..',
    '..............'
], CLUMP_MAP);

const crawler2 = fromAscii([
    '..............',
    '..............',
    '...oooooo.....',
    '..odppppdo....',
    '.odpppppppdo..',
    '.dpppffppppdo.',
    '.dpppppppppdo.',
    '.odpppppppdo..',
    '..oddpppddo...',
    '...o.o..o.o...',
    '...o.o..o.o...',
    '..............'
], CLUMP_MAP);

// Плевун 14x14: столбик с тёмной пастью.
const spitter1 = fromAscii([
    '....oooooo....',
    '...odppppdo...',
    '..odppppppdo..',
    '..dpppppppppd.',
    '..dpppffppppd.',
    '..dpppppppppd.',
    '..dpoooopppd..',
    '..dpppppppppd.',
    '...dpppppppd..',
    '...odpppppdo..',
    '....odpppdo...',
    '....o.o.o.o...',
    '...o..o..o....',
    '..............'
], CLUMP_MAP);

const spitter2 = fromAscii([
    '....oooooo....',
    '...odppppdo...',
    '..odppppppdo..',
    '..dpppppppppd.',
    '..dpppffppppd.',
    '..dpppppppppd.',
    '..dppoooopppd.',
    '..dpppppppppd.',
    '...dpppppppd..',
    '...odpppppdo..',
    '....odpppdo...',
    '...o..o..o....',
    '....o.o.o.o...',
    '..............'
], CLUMP_MAP);

// Наст 20x20: тяжёлая корка с трещинами; медленно давит.
const heavy1 = fromAscii([
    '.......oooooo.......',
    '.....oodppppdoo.....',
    '....odppppppppdo....',
    '...odppppppppppdo...',
    '...dpppddppppppppd..',
    '..dppppppddpppppppd.',
    '..dppppppppddppffpd.',
    '..dpppffppppppppfpd.',
    '..dpppffppppppppppd.',
    '..dpppppppppppppppd.',
    '..dpppppppppppppppd.',
    '..dpppppppppppppppd.',
    '..odpppppppppppppdo.',
    '..odqpppppppppppdo..',
    '...odqqppppqqpdo....',
    '....odqpppqqpdo.....',
    '.....oddddddddo.....',
    '...ooooddddooooo....',
    '....................',
    '....................'
], CLUMP_MAP);

const heavy2 = fromAscii([
    '.......oooooo.......',
    '.....oodppppdoo.....',
    '....odppppppppdo....',
    '...odppppppppppdo...',
    '...dpppppppddppppd..',
    '..dpppddpppppddpppd.',
    '..dpppppddpppppffpd.',
    '..dpppffpppddpppfpd.',
    '..dpppffppppppppppd.',
    '..dpppppppppppppppd.',
    '..dpppppppppppppppd.',
    '..dpppppppppppppppd.',
    '..odpppppppppppppdo.',
    '..odqpppppppppppdo..',
    '...odqqppppqqpdo....',
    '....odqpppqqpdo.....',
    '.....oddddddddo.....',
    '...ooooddddooooo....',
    '....................',
    '....................'
], CLUMP_MAP);

save(crawler1, 'chars/clump_crawler_1.png');
save(crawler2, 'chars/clump_crawler_2.png');
save(spitter1, 'chars/clump_spitter_1.png');
save(spitter2, 'chars/clump_spitter_2.png');
save(heavy1, 'chars/clump_heavy_1.png');
save(heavy2, 'chars/clump_heavy_2.png');

// атлас сгустков (кадры разной ширины — вписаны по низу-центру в ячейку)
const CLUMP_W = SIZES.clump.w;
const CLUMP_H = SIZES.clump.h;
/** Canvas → кадр w×h×4 с выравниванием по низу-центру. */
const toClumpFrame = (c) => {
    const out = new Uint8Array(CLUMP_W * CLUMP_H * 4);
    const dx = Math.floor((CLUMP_W - c.width) / 2);
    for (let y = 0; y < c.height; y++) {
        for (let x = 0; x < c.width; x++) {
            const rgba = c.get(x, y);
            if (!rgba) continue;
            out.set(rgba, ((CLUMP_H - c.height + y) * CLUMP_W + dx + x) * 4);
        }
    }
    return out;
};
const clumpSheet = buildAtlas(
    [crawler1, crawler2, spitter1, spitter2, heavy1, heavy2].map(toClumpFrame),
    ['clump_crawler_1', 'clump_crawler_2', 'clump_spitter_1', 'clump_spitter_2', 'clump_heavy_1', 'clump_heavy_2'],
    {
        frameW: CLUMP_W, frameH: CLUMP_H, image: 'clumps_sheet.png',
        animations: { clump_crawler: [0, 1], clump_spitter: [2, 3], clump_heavy: [4, 5] }
    }
);
writeFileSync(OUT + 'chars/clumps_sheet.png', clumpSheet.png);
writeFileSync(OUT + 'chars/clumps_sheet.json', JSON.stringify(clumpSheet.json, null, 2));
console.log(`  chars/clumps_sheet.png (+ .json): ${CLUMP_W * 6}x${CLUMP_H}`);

// ---------- атлас героя (Spritesheet: один PNG + JSON + animations) ----------

const FRAME_W = SIZES.char.w;
const FRAME_H = SIZES.char.h;
const heroSheet = buildAtlas(
    [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2].map((c) => new Uint8Array(c.data)),
    ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2'],
    {
        frameW: FRAME_W, frameH: FRAME_H, image: 'hero_sheet.png',
        animations: {
            hero_walk_down: [0, 1],
            hero_walk_up: [2, 3],
            hero_walk_side: [4, 5]
        }
    }
);
writeFileSync(OUT + 'chars/hero_sheet.png', heroSheet.png);
writeFileSync(OUT + 'chars/hero_sheet.json', JSON.stringify(heroSheet.json, null, 2));
console.log(`  chars/hero_sheet.png (+ .json): ${FRAME_W * 6}x${FRAME_H}`);

// ---------- атлас NPC: idle-кадры из опорных (bobFrame) ----------

const npcSheet = buildAtlas(
    [
        new Uint8Array(elderIrwin.data),
        bobFrame(new Uint8Array(elderIrwin.data), FRAME_W, FRAME_H),
        new Uint8Array(traderMila.data),
        bobFrame(new Uint8Array(traderMila.data), FRAME_W, FRAME_H)
    ],
    ['elder_irwin_1', 'elder_irwin_2', 'trader_mila_1', 'trader_mila_2'],
    {
        frameW: FRAME_W, frameH: FRAME_H, image: 'npcs_sheet.png',
        animations: { elder_irwin_idle: [0, 1], trader_mila_idle: [2, 3] }
    }
);
writeFileSync(OUT + 'chars/npcs_sheet.png', npcSheet.png);
writeFileSync(OUT + 'chars/npcs_sheet.json', JSON.stringify(npcSheet.json, null, 2));
console.log(`  chars/npcs_sheet.png (+ .json): ${FRAME_W * 4}x${FRAME_H}`);

console.log('Готово: ' + OUT);