/**
* Генератор пиксель-арта «Пепельных лугов».
* Запуск: 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, 'P3', 'P4', 'P2');
const r = rng(9);
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(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);