Newer
Older
rpg / 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, fillDiamond, fromAscii, rng } from './canvas.mjs';

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

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

/** Ромб с зерном пепла/травы. */
function tileBase(base, light, dark, seed, specks) {
    const c = new Canvas(32, 16);
    fillDiamond(c, 16, 8, 32, 16, base, light, dark);
    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() {
    const c = tileBase('G1', 'G2', 'G0', 11, 26);
    // Сухие стебли: 1-2 пикселя G2H.
    const r = rng(77);
    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() {
    const c = tileBase('P4', 'P5', 'P3', 23, 30);
    // Утоптанный камешек и след колеи.
    const r = rng(31);
    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;
}

function waterTile() {
    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);
        c.set(x, y, 'W2');
        c.set(x + 1, 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() {
    // Пепельный бугор: светлее травы, тёмный обод по нижней грани.
    const c = new Canvas(32, 16);
    fillDiamond(c, 16, 8, 32, 16, 'P4', 'P6', 'P3');
    const r = rng(9);
    for (let i = 0; i < 10; i++) {
        const x = 3 + Math.floor(r() * 26);
        const y = 4 + Math.floor(r() * 9);
        if (c.get(x, y)) c.set(x, y, 'P5H');
    }
    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(32, 48);
    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;
}

// ---------- персонажи 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...',
    '...otttttttto...',
    '....ooSSSSoo....',
    '....oSoPSoSo....',
    '....oSsSsSso....',
    '.....oSSSSo.....',
    '.....oCCCCo.....',
    '...oCcCccCcCo...',
    '...oCcCccCcCo...',
    '...oCccCcCccCo..',
    '...oCcCccCcCo...',
    '...obBccccBbo...',
    '...oCcCccCcCo...',
    '...oCccCcCccCo..',
    '....oCooCoo.....',
    '....oPo..oPo....',
    '....oPo..oPo....',
    '...oPPo..oPPo...',
    '...oPPo..oPPo...',
    '....oo....oo....',
    '................'
], HERO_MAP);

// Второй кадр ходьбы: ноги сошлись (средняя фаза).
const heroDown2 = fromAscii([
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '...otttttttto...',
    '....ooSSSSoo....',
    '....oSoPSoSo....',
    '....oSsSsSso....',
    '.....oSSSSo.....',
    '.....oCCCCo.....',
    '...oCcCccCcCo...',
    '...oCcCccCcCo...',
    '...oCccCcCccCo..',
    '...oCcCccCcCo...',
    '...obBccccBbo...',
    '...oCcCccCcCo...',
    '...oCccCcCccCo..',
    '....oCo.oCo.....',
    '....oPo.oPo.....',
    '....oPo.oPo.....',
    '....oPPooPPo....',
    '.....oPPPPo.....',
    '......oooo......',
    '................'
], HERO_MAP);

// Спина: капюшон, узел плаща, лампа на поясе (F1 — единственный тёплый пиксель).
const heroUp1 = fromAscii([
    '................',
    '.....oooooo.....',
    '....oTTTTTTo....',
    '...oTttttttTo...',
    '...otttttttto...',
    '.....oTttTo.....',
    '....ooCooCoo....',
    '...oCCCCCCCCo...',
    '...oCcCccCcCo...',
    '...oCccCcCccCo..',
    '...oCcCccCcCo...',
    '...oCcCcccCcCo..',
    '...oCccCcCccCo..',
    '...oCcccccccCo..',
    '...oCffCCffCo...',
    '...oCcCccCcCo...',
    '....oCooCoo.....',
    '....oPo..oPo....',
    '....oPo..oPo....',
    '...oPPo..oPPo...',
    '...oPPo..oPPo...',
    '....oo....oo....',
    '................',
    '................'
], { ...HERO_MAP, f: 'F0' });

const heroUp2 = heroUp1; // для спины ходьба читается слабо — оставляем один кадр

const heroSide1 = fromAscii([
    '................',
    '.....ooooo......',
    '....oTTTTTo.....',
    '...oTtttttto....',
    '...ottttttto....',
    '.....oSSSSo.....',
    '.....oSSoPo.....',
    '.....oSsSo......',
    '.....oSSSo......',
    '.....oCCCCo.....',
    '....oCcCccCo....',
    '....oCccCcCo....',
    '....oCcCccCo....',
    '....oCcccBbo....',
    '....oCcCccCo....',
    '....oCccCcCo....',
    '.....oCoCco.....',
    '.....oPo.Po.....',
    '.....oPo.Po.....',
    '....oPPo.PPo....',
    '....oPPo.PPo....',
    '.....oo...oo....',
    '................',
    '................'
], HERO_MAP);

// Шаг в бок: передняя нога вытянута.
const heroSide2 = fromAscii([
    '................',
    '.....ooooo......',
    '....oTTTTTo.....',
    '...oTtttttto....',
    '...ottttttto....',
    '.....oSSSSo.....',
    '.....oSSoPo.....',
    '.....oSsSo......',
    '.....oSSSo......',
    '.....oCCCCo.....',
    '....oCcCccCo....',
    '....oCccCcCo....',
    '....oCcCccCo....',
    '....oCcccBbo....',
    '....oCcCccCo....',
    '....oCccCcCo....',
    '.....oCoCco.....',
    '....oPo..Po.....',
    '...oPPo..oPo....',
    '...oPPo...PPo...',
    '....oo....PPo...',
    '..........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(pathTile(), 'tiles/path.png');
save(waterTile(), 'tiles/water.png');
save(ashTile(), 'tiles/ash.png');
save(bellflowerTile(), 'tiles/bellflower.png');
save(treeTile(), 'tiles/tree.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');

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