/**
* Генератор пиксель-арта «Пепельных лугов».
* Запуск: 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);
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');
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;
}
/** Дом Звенца 32x56: стены из вагонных щитов, крыша из платформ. Tall-объект 1.75 юнита. */
function houseTile() {
const c = new Canvas(32, 56);
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;
}
/** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */
function towerTile() {
const c = new Canvas(32, 80);
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(pathTile(), 'tiles/path.png');
save(waterTile(), 'tiles/water.png');
save(ashTile(), 'tiles/ash.png');
save(bellflowerTile(), 'tiles/bellflower.png');
save(treeTile(), 'tiles/tree.png');
save(houseTile(), 'tiles/house.png');
save(towerTile(), 'tiles/tower.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');
// атлас фауны (кадры 16x24 — та же сетка, что у героя)
const FRAME_W_FAUNA = 16;
const FRAME_H_FAUNA = 24;
const faunaFrames = [deerStand, deerWalk];
const faunaNames = ['fauna_deer_1', 'fauna_deer_2'];
const faunaSheetW = FRAME_W_FAUNA * faunaFrames.length;
const faunaSheetCanvas = new Canvas(faunaSheetW, FRAME_H_FAUNA);
const faunaJson = {};
for (let i = 0; i < faunaFrames.length; i++) {
const src = faunaFrames[i];
for (let y = 0; y < FRAME_H_FAUNA; y++) {
for (let x = 0; x < FRAME_W_FAUNA; x++) {
const rgba = src.get(x, y);
if (rgba) faunaSheetCanvas.set(i * FRAME_W_FAUNA + x, y, rgba);
}
}
faunaJson[faunaNames[i]] = {
frame: { x: i * FRAME_W_FAUNA, y: 0, w: FRAME_W_FAUNA, h: FRAME_H_FAUNA },
rotated: false,
trimmed: false,
sourceSize: { w: FRAME_W_FAUNA, h: FRAME_H_FAUNA }
};
}
writeFileSync(OUT + 'chars/fauna_sheet.png', faunaSheetCanvas.toPng());
writeFileSync(
OUT + 'chars/fauna_sheet.json',
JSON.stringify(
{
frames: faunaJson,
meta: { image: 'fauna_sheet.png', size: { w: faunaSheetW, h: FRAME_H_FAUNA }, scale: 1 }
},
null,
2
)
);
console.log(` chars/fauna_sheet.png (+ .json): ${faunaSheetW}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');
// атлас сгустков (кадры разной ширины — выравнивание по низу ячейки 20x20)
const clumpFrames = [crawler1, crawler2, spitter1, spitter2, heavy1, heavy2];
const clumpNames = ['clump_crawler_1', 'clump_crawler_2', 'clump_spitter_1', 'clump_spitter_2', 'clump_heavy_1', 'clump_heavy_2'];
const CLUMP_W = 20;
const CLUMP_H = 20;
const clumpSheetW = CLUMP_W * clumpFrames.length;
const clumpSheet = new Canvas(clumpSheetW, CLUMP_H);
const clumpJson = {};
for (let i = 0; i < clumpFrames.length; i++) {
const src = clumpFrames[i];
for (let y = 0; y < src.height; y++) {
for (let x = 0; x < src.width; x++) {
const rgba = src.get(x, y);
if (rgba) {
clumpSheet.set(i * CLUMP_W + x, CLUMP_H - src.height + y, rgba);
}
}
}
clumpJson[clumpNames[i]] = {
frame: { x: i * CLUMP_W, y: 0, w: CLUMP_W, h: CLUMP_H },
rotated: false,
trimmed: false,
sourceSize: { w: CLUMP_W, h: CLUMP_H }
};
}
writeFileSync(OUT + 'chars/clumps_sheet.png', clumpSheet.toPng());
writeFileSync(
OUT + 'chars/clumps_sheet.json',
JSON.stringify(
{
frames: clumpJson,
meta: { image: 'clumps_sheet.png', size: { w: clumpSheetW, h: CLUMP_H }, scale: 1 }
},
null,
2
)
);
console.log(` chars/clumps_sheet.png (+ .json): ${clumpSheetW}x${CLUMP_H}`);
// ---------- атлас героя (Spritesheet: один PNG + JSON) ----------
const FRAME_W = 16;
const FRAME_H = 24;
const heroFrames = [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2];
const heroNames = ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2'];
const sheetW = FRAME_W * heroFrames.length;
const sheetCanvas = new Canvas(sheetW, FRAME_H);
const framesJson = {};
for (let i = 0; i < heroFrames.length; i++) {
const src = heroFrames[i];
// Построчное копирование: строка атласа шире кадра, цельный set ломает шаг.
for (let y = 0; y < FRAME_H; y++) {
for (let x = 0; x < FRAME_W; x++) {
const rgba = src.get(x, y);
if (rgba) sheetCanvas.set(i * FRAME_W + x, y, rgba);
}
}
framesJson[heroNames[i]] = {
frame: { x: i * FRAME_W, y: 0, w: FRAME_W, h: FRAME_H },
rotated: false,
trimmed: false,
sourceSize: { w: FRAME_W, h: FRAME_H }
};
}
writeFileSync(OUT + 'chars/hero_sheet.png', sheetCanvas.toPng());
writeFileSync(
OUT + 'chars/hero_sheet.json',
JSON.stringify(
{
frames: framesJson,
meta: { image: 'hero_sheet.png', size: { w: sheetW, h: FRAME_H }, scale: 1 }
},
null,
2
)
);
console.log(` chars/hero_sheet.png (+ .json): ${sheetW}x${FRAME_H}`);
console.log('Готово: ' + OUT);