diff --git a/apps/game/assets/chars/clumps_sheet.json b/apps/game/assets/chars/clumps_sheet.json index e095675..1d098a7 100644 --- a/apps/game/assets/chars/clumps_sheet.json +++ b/apps/game/assets/chars/clumps_sheet.json @@ -92,5 +92,19 @@ "h": 20 }, "scale": 1 + }, + "animations": { + "clump_crawler": [ + "clump_crawler_1", + "clump_crawler_2" + ], + "clump_spitter": [ + "clump_spitter_1", + "clump_spitter_2" + ], + "clump_heavy": [ + "clump_heavy_1", + "clump_heavy_2" + ] } } \ No newline at end of file diff --git a/apps/game/assets/chars/clumps_sheet.png b/apps/game/assets/chars/clumps_sheet.png index ca3bb01..f259765 100644 --- a/apps/game/assets/chars/clumps_sheet.png +++ b/apps/game/assets/chars/clumps_sheet.png Binary files differ diff --git a/apps/game/assets/chars/fauna_sheet.json b/apps/game/assets/chars/fauna_sheet.json index 1cf12da..7ba44b5 100644 --- a/apps/game/assets/chars/fauna_sheet.json +++ b/apps/game/assets/chars/fauna_sheet.json @@ -36,5 +36,11 @@ "h": 24 }, "scale": 1 + }, + "animations": { + "fauna_deer_walk": [ + "fauna_deer_1", + "fauna_deer_2" + ] } } \ No newline at end of file diff --git a/apps/game/assets/chars/hero_sheet.json b/apps/game/assets/chars/hero_sheet.json index 368c4e5..68b9132 100644 --- a/apps/game/assets/chars/hero_sheet.json +++ b/apps/game/assets/chars/hero_sheet.json @@ -92,5 +92,19 @@ "h": 24 }, "scale": 1 + }, + "animations": { + "hero_walk_down": [ + "hero_down_1", + "hero_down_2" + ], + "hero_walk_up": [ + "hero_up_1", + "hero_up_2" + ], + "hero_walk_side": [ + "hero_side_1", + "hero_side_2" + ] } } \ No newline at end of file diff --git a/apps/game/assets/chars/npcs_sheet.json b/apps/game/assets/chars/npcs_sheet.json new file mode 100644 index 0000000..ac671e2 --- /dev/null +++ b/apps/game/assets/chars/npcs_sheet.json @@ -0,0 +1,78 @@ +{ + "frames": { + "elder_irwin_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "elder_irwin_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "trader_mila_1": { + "frame": { + "x": 32, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "trader_mila_2": { + "frame": { + "x": 48, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "npcs_sheet.png", + "size": { + "w": 64, + "h": 24 + }, + "scale": 1 + }, + "animations": { + "elder_irwin_idle": [ + "elder_irwin_1", + "elder_irwin_2" + ], + "trader_mila_idle": [ + "trader_mila_1", + "trader_mila_2" + ] + } +} \ No newline at end of file diff --git a/apps/game/assets/chars/npcs_sheet.png b/apps/game/assets/chars/npcs_sheet.png new file mode 100644 index 0000000..8c86936 --- /dev/null +++ b/apps/game/assets/chars/npcs_sheet.png Binary files differ diff --git a/apps/game/assets/tiles/water.png b/apps/game/assets/tiles/water.png deleted file mode 100644 index 5ed40b2..0000000 --- a/apps/game/assets/tiles/water.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/water_1.png b/apps/game/assets/tiles/water_1.png new file mode 100644 index 0000000..5ed40b2 --- /dev/null +++ b/apps/game/assets/tiles/water_1.png Binary files differ diff --git a/apps/game/assets/tiles/water_2.png b/apps/game/assets/tiles/water_2.png new file mode 100644 index 0000000..eada5c3 --- /dev/null +++ b/apps/game/assets/tiles/water_2.png Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 2f4cfa9..060d8b6 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -38,7 +38,8 @@ static readonly ASSET_KEYS = [ 'tiles/grass', 'tiles/path', - 'tiles/water', + 'tiles/water_1', + 'tiles/water_2', 'tiles/ash', 'tiles/bellflower', 'tiles/tree', diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 8c50896..79909a5 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -596,7 +596,7 @@ return new Map([ [TILES.GRASS, a.texture('tiles/grass')], [TILES.PATH, a.texture('tiles/path')], - [TILES.WATER, a.texture('tiles/water')], + [TILES.WATER, a.texture('tiles/water_1')], [TILES.ASH, a.texture('tiles/ash')], [TILES.BELLFLOWER, a.texture('tiles/bellflower')], [TILES.TREE, a.texture('tiles/tree')], diff --git a/apps/game/tools/aiart/atlas.mjs b/apps/game/tools/aiart/atlas.mjs index 19050a2..61e67f4 100644 --- a/apps/game/tools/aiart/atlas.mjs +++ b/apps/game/tools/aiart/atlas.mjs @@ -14,6 +14,7 @@ import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; import { findFigures, resizeRect, quantize } from '@rpg/engine/tools/imaging.mjs'; import { buildAtlas } from '@rpg/engine/tools/atlas.mjs'; +import { squashFrame } from '@rpg/engine/tools/frames.mjs'; import { remap, parseColor } from './recolor.mjs'; import { PALETTE } from '../pixelart/palette.mjs'; @@ -96,7 +97,7 @@ const [fw, fh] = char.frameSize; // Ремап «поплавших» цветов генерации — после квантизации (точные цвета палитры). const pairs = Object.entries(char.remap ?? {}).map(([from, to]) => [parseColor(from), parseColor(to)]); - const frames = [], names = []; + const frames = [], names = [], animations = {}; mkdirSync(join(FRAMES, char.id), { recursive: true }); for (const [dirName, set] of Object.entries(char.frameSets)) { if (!existsSync(join(SHEETS, char.id, `${set.view}_${char.seed}.png`))) { @@ -107,10 +108,11 @@ if (Math.max(...set.poses) >= figures.length) { throw new Error(`${set.view}: найдено фигур ${figures.length}, поза ${Math.max(...set.poses)} не выбирается (см. crop в манифесте)`); } - // Бобинг: 2-й кадр — производный от первого (сдвиг вниз на 1px, верх - // прозрачный) — различимая ходьба из одинаковых стоячих поз; вторая - // поза листа тогда не используется. + // Приседание: 2-й кадр — производный от первого (сдвиг вниз на 1px, + // верх прозрачный) — различимая ходьба из одинаковых стоячих поз; + // вторая поза листа тогда не используется. const usedPoses = set.bob ? [set.poses[0]] : set.poses; + const setFirst = frames.length; for (const pose of usedPoses) { let rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal); if (pairs.length) rgba = remap(rgba, pairs); @@ -119,13 +121,15 @@ names.push(`${char.id}_${dirName}_${n}`); writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(rgba))); if (set.bob) { - const bob = new Uint8Array(fw * fh * 4); - bob.set(rgba.subarray(0, (fh - 1) * fw * 4), fw * 4); + const bob = squashFrame(rgba, fw, fh); frames.push(bob); names.push(`${char.id}_${dirName}_${frames.length}`); writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(bob))); } } + animations[`${char.id}_${dirName}`] = frames + .slice(setFirst) + .map((_, i) => setFirst + i); } if (!frames.length) throw new Error('ни одного ракурса нет — сначала atlas.mjs gen'); const outDir = join(BUILD, char.id); @@ -133,7 +137,7 @@ // meta.image — имя PNG рядом с JSON (Pixi резолвит его относительно URL // атласа; путь с каталогом даст /chars/chars/… → 404 в игре). const { png, json } = buildAtlas(frames, names, { - frameW: fw, frameH: fh, image: basename(char.target) + frameW: fw, frameH: fh, image: basename(char.target), animations }); writeFileSync(join(outDir, char.target), png); writeFileSync(join(outDir, char.target.replace(/\.png$/, '.json')), JSON.stringify(json, null, 2)); diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs index bb964e9..f70abbd 100644 --- a/apps/game/tools/pixelart/gen.mjs +++ b/apps/game/tools/pixelart/gen.mjs @@ -6,6 +6,8 @@ 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'; /** Канвас с палитрой игры (ключи цветов — из palette.mjs). */ @@ -61,15 +63,16 @@ return c; } -function waterTile() { +/** Вода: кадр 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); - c.set(x, y, 'W2'); - c.set(x + 1, y, 'W2'); + 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); @@ -647,7 +650,8 @@ console.log('Генерация тайлов:'); save(grassTile(), 'tiles/grass.png'); save(pathTile(), 'tiles/path.png'); -save(waterTile(), 'tiles/water.png'); +save(waterTile(0), 'tiles/water_1.png'); +save(waterTile(1), 'tiles/water_2.png'); save(ashTile(), 'tiles/ash.png'); save(bellflowerTile(), 'tiles/bellflower.png'); save(treeTile(), 'tiles/tree.png'); @@ -735,39 +739,17 @@ // атлас фауны (кадры 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); - } +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] } } - 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}`); +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* — «жизнь» загорается, когда сгусток просыпается. @@ -894,83 +876,71 @@ 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']; +// атлас сгустков (кадры разной ширины — вписаны по низу-центру в ячейку 20x20) 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); - } +/** 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); } } - 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 - ) + 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] } + } ); -console.log(` chars/clumps_sheet.png (+ .json): ${clumpSheetW}x${CLUMP_H}`); +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) ---------- +// ---------- атлас героя (Spritesheet: один PNG + JSON + animations) ---------- 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); +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] } } - 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}`); +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); \ No newline at end of file diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index 4b8030f..83a24a7 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -12,7 +12,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-масштаб, квантизация в палитру, обзорный лист; `findFigures`/`resizeRect` — фигуры на однотонном фоне | -| `packages/engine/tools/atlas.mjs` | `buildAtlas` — кадры в строку + Pixi Spritesheet JSON | +| `packages/engine/tools/atlas.mjs` | `buildAtlas` — кадры в строку + Pixi Spritesheet JSON (с `animations`) | +| `packages/engine/tools/frames.mjs` | Производные кадры из опорных: shift/bob/squash/swing/mirror, `deriveCycle` | | `apps/game/tools/pixelart/palette.mjs` | Палитра игры из 32 цветов (hex + RGBA) — сценарный слой | | `apps/game/tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `apps/game/assets/` (канвас с палитрой игры) | @@ -67,9 +68,30 @@ ``` Загрузка в игре — `assets.loadAtlas('chars/hero_sheet')`, кадры — `assets.frames(key, 'hero_walk')`. +Генераторы могут заполнить `animations` (имя → список имён кадров, формат Pixi) — +такие анимации игра берёт целиком: `assets.animation('chars/hero_sheet', 'hero_walk_down')`. -Сборка атласа из готовых кадров — `buildAtlas(frames, names, { frameW, frameH, image })` -из `@rpg/engine/tools/atlas.mjs` → `{ png, json }` (формат выше). +Сборка атласа из готовых кадров — `buildAtlas(frames, names, { frameW, frameH, image, animations? })` +из `@rpg/engine/tools/atlas.mjs` → `{ png, json }` (формат выше); `animations` — +`Record<имя, number[]>` (индексы в `names`). + +## Производные кадры: frames.mjs + +Дешёвые циклы из 1–2 опорных кадров — `@rpg/engine/tools/frames.mjs` +(операции над RGBA `Uint8Array`, всё nearest): + +- `shiftFrame(src, w, h, dx, dy)` — сдвиг, пустота — прозрачность; +- `bobFrame(src, w, h, dy = 1)` — «парение» вверх (idle-кадр сгустков/духов); +- `squashFrame(src, w, h)` — «приседание» вниз на 1 px (2-й кадр ходьбы из + стоячей позы — так AI-листы с одинаковыми позами становятся циклом); +- `swingFrame(src, w, h, angle, pivot?)` — nearest-поворот вокруг опоры + (низ-центр по умолчанию) — кадры покачивания; +- `mirrorFrame(src, w, h)` — зеркалирование (кадр «вправо» → «влево»); +- `deriveCycle(src, w, h, ops)` — цикл по списку дескрипторов + `{ dx?, dy?, bob?, squash?, swing?, mirror? }`. + +Пример: в `gen.mjs` NPC idle-кадры — `bobFrame` от опорного; вода — два +кадра с бликами, сдвинутыми на 1 px (кадр 2 рисуется со сдвигом штрихов). ## Фигуры на однотонном фоне diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 1ebda20..ddc22f8 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -11,7 +11,7 @@ await assets.load(['tiles/grass', 'chars/hero_down_1'], (p) => setProgressBar(p)); assets.texture('tiles/grass'); // Texture; бросает, если не загружено -assets.has('tiles/water'); +assets.has('tiles/water_1'); ``` **Грабли**: `Assets.load` без предварительного `Assets.init({})` висит навсегда @@ -24,11 +24,13 @@ const sheet = await assets.loadAtlas('chars/hero_sheet'); assets.frames('chars/hero_sheet', 'hero_walk'); // кадры hero_walk_1, hero_walk_2, ... -assets.animation('chars/hero_sheet', 'walk'); // готовая анимация из sheet.animations +assets.animation('chars/hero_sheet', 'hero_walk_down'); // готовая анимация из sheet.animations ``` -`frames()` сортирует кадры по числу в конце имени. Пример генерации атласа — -в [art-pipeline.md](art-pipeline.md). +`frames()` сортирует кадры по числу в конце имени. `animation()` возвращает +кадры из `sheet.animations` (их пишут генераторы — `buildAtlas(..., { animations })`, +см. [art-pipeline.md](art-pipeline.md)); готовые списки удобно отдавать в +`SpriteAnimator` (`anim.md`). Пример генерации атласа — в [art-pipeline.md](art-pipeline.md). ## FrameAnimation diff --git a/packages/engine/tools/__tests__/atlas.test.mjs b/packages/engine/tools/__tests__/atlas.test.mjs index fa3f1bc..48b5771 100644 --- a/packages/engine/tools/__tests__/atlas.test.mjs +++ b/packages/engine/tools/__tests__/atlas.test.mjs @@ -100,6 +100,21 @@ 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(); }); + it('animations: индексы → списки имён кадров (формат Pixi)', () => { + const { json } = buildAtlas([frame(1), frame(2), frame(3)], ['a', 'b', 'c'], { + frameW: 16, frameH: 24, image: 'x.png', + animations: { walk: [0, 1], idle: [0] } + }); + expect(json.animations).toEqual({ walk: ['a', 'b'], idle: ['a'] }); + // Без animations ключа — поле отсутствует (обратная совместимость) + const plain = buildAtlas([frame(1)], ['a'], { frameW: 16, frameH: 24, image: 'x.png' }); + expect('animations' in plain.json).toBe(false); + }); + it('animations: индекс вне кадров — ошибка', () => { + expect(() => buildAtlas([frame(1)], ['a'], { + frameW: 16, frameH: 24, image: 'x.png', animations: { walk: [1] } + })).toThrow(); + }); }); describe('roundtrip findFigures -> resizeRect -> quantize на PNG', () => { diff --git a/packages/engine/tools/__tests__/frames.test.mjs b/packages/engine/tools/__tests__/frames.test.mjs new file mode 100644 index 0000000..4d3cf3f --- /dev/null +++ b/packages/engine/tools/__tests__/frames.test.mjs @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { shiftFrame, bobFrame, squashFrame, mirrorFrame, swingFrame, deriveCycle } from '../frames.mjs'; + +/** Кадр 4×4 из строк символов: '.' — прозрачный, '#' — непрозрачный белый. */ +const frame = (rows) => { + const w = rows[0].length; + const h = rows.length; + const rgba = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (rows[y][x] === '#') rgba[(y * w + x) * 4 + 3] = 255; + } + } + return rgba; +}; + +/** Кадр обратно в строки символов (по alpha). */ +const toRows = (rgba, w, h) => { + const rows = []; + for (let y = 0; y < h; y++) { + let row = ''; + for (let x = 0; x < w; x++) row += rgba[(y * w + x) * 4 + 3] ? '#' : '.'; + rows.push(row); + } + return rows; +}; + +const SRC = frame([ + '....', + '.##.', + '.##.', + '....' +]); + +describe('shiftFrame', () => { + it('сдвигает содержимое, пустота — прозрачность', () => { + expect(toRows(shiftFrame(SRC, 4, 4, 1, 0), 4, 4)).toEqual([ + '....', + '..##', + '..##', + '....' + ]); + expect(toRows(shiftFrame(SRC, 4, 4, 0, -1), 4, 4)).toEqual([ + '.##.', + '.##.', + '....', + '....' + ]); + // Сдвиг за край — кадр полностью прозрачный + expect(toRows(shiftFrame(SRC, 4, 4, 4, 0), 4, 4)).toEqual(['....', '....', '....', '....']); + }); +}); + +describe('bobFrame', () => { + it('поднимает тело на dy (по умолчанию 1)', () => { + expect(toRows(bobFrame(SRC, 4, 4), 4, 4)).toEqual([ + '.##.', + '.##.', + '....', + '....' + ]); + expect(toRows(bobFrame(SRC, 4, 4, 2), 4, 4)).toEqual([ + '.##.', + '....', + '....', + '....' + ]); + }); +}); + +describe('squashFrame', () => { + it('приседание: спрайт на 1 px вниз, нижний ряд срезается', () => { + const src = frame([ + '....', + '.##.', + '.##.', + '.##.' + ]); + expect(toRows(squashFrame(src, 4, 4), 4, 4)).toEqual([ + '....', + '....', + '.##.', + '.##.' + ]); + // Спрайт стал ниже: было 3 ряда содержимого, осталось 2 + const out = squashFrame(src, 4, 4); + expect(out[(0 * 4 + 1) * 4 + 3]).toBe(0); + expect(out[(3 * 4 + 1) * 4 + 3]).toBe(255); + }); +}); + +describe('mirrorFrame', () => { + it('отражает по горизонтали', () => { + const src = frame([ + '#...', + '.##.', + '....', + '....' + ]); + expect(toRows(mirrorFrame(src, 4, 4), 4, 4)).toEqual([ + '...#', + '.##.', + '....', + '....' + ]); + }); +}); + +describe('swingFrame', () => { + it('поворот вокруг низ-центра: 0 рад — кадр без изменений', () => { + expect(swingFrame(SRC, 4, 4, 0)).toEqual(SRC); + }); + + it('точка опоры остаётся на месте при малом угле', () => { + const out = swingFrame(SRC, 4, 4, 0.2); + // Нижняя пара пикселей фигуры (y=2) около оси — почти не сместилась + expect(out[(2 * 4 + 1) * 4 + 3]).toBe(255); + // Верх фигуры (y=1) ушла за пределы или сместилась — но что-то осталось + expect(toRows(out, 4, 4).join('')).not.toBe('................'); + }); +}); + +describe('deriveCycle', () => { + it('по дескриптору на кадр, операции применяются по порядку', () => { + const cycle = deriveCycle(SRC, 4, 4, [{}, { bob: 1 }, { mirror: true }]); + expect(cycle).toHaveLength(3); + expect(toRows(cycle[0], 4, 4)).toEqual(toRows(SRC, 4, 4)); + expect(toRows(cycle[1], 4, 4)).toEqual(toRows(bobFrame(SRC, 4, 4), 4, 4)); + expect(toRows(cycle[2], 4, 4)).toEqual(toRows(mirrorFrame(SRC, 4, 4), 4, 4)); + }); +}); \ No newline at end of file diff --git a/packages/engine/tools/atlas.mjs b/packages/engine/tools/atlas.mjs index 7cac4ce..dc1b13a 100644 --- a/packages/engine/tools/atlas.mjs +++ b/packages/engine/tools/atlas.mjs @@ -9,10 +9,12 @@ * Кадры в строку атласа шириной frames.length × frameW. * @param frames массив Uint8Array (frameW×frameH×4, RGBA) * @param names имена кадров (ключи JSON.frames) - * @param opts { frameW, frameH, image } — image: имя PNG рядом с JSON + * @param opts { frameW, frameH, image, animations? } — image: имя PNG рядом + * с JSON; animations: Record<имя, number[]> (индексы в names) — + * попадает в JSON.animations списками имён кадров (формат Pixi) * @returns { png: Buffer, json: object } */ -export function buildAtlas(frames, names, { frameW, frameH, image }) { +export function buildAtlas(frames, names, { frameW, frameH, image, animations }) { if (frames.length !== names.length) { throw new Error(`кадры и имена разной длины: ${frames.length} != ${names.length}`); } @@ -35,11 +37,23 @@ 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 } - } + const json = { + frames: framesJson, + meta: { image, size: { w: sheetW, h: frameH }, scale: 1 } }; + if (animations) { + // Формат Pixi Spritesheet: имя анимации → список имён кадров. + json.animations = Object.fromEntries( + Object.entries(animations).map(([name, idx]) => [ + name, + idx.map((i) => { + if (i < 0 || i >= names.length) { + throw new Error(`анимация ${name}: индекс ${i} вне кадров (${names.length})`); + } + return names[i]; + }) + ]) + ); + } + return { png: encodePng(sheetW, frameH, Buffer.from(sheet)), json }; } \ No newline at end of file diff --git a/packages/engine/tools/frames.mjs b/packages/engine/tools/frames.mjs new file mode 100644 index 0000000..db29148 --- /dev/null +++ b/packages/engine/tools/frames.mjs @@ -0,0 +1,108 @@ +/** + * Операции над кадрами (Uint8Array w×h×4 RGBA) — синтез производных кадров + * из опорных: псевдо-walk-циклы, idle-кадры оживителей, зеркалирование. + * Всё nearest, без сглаживания — пиксель-арт не мылится. + * Модуль жанронезависим: знает только формат RGBA-кадра. + */ + +/** Прозрачный ли пиксель (alpha 0). */ +const isEmpty = (rgba, i) => rgba[i + 3] === 0; + +/** Копия пикселя src[i] в dst[j] (или прозрачность, если src вне кадра). */ +function blit(src, dst, w, h, sx, sy, dx, dy) { + const j = (dy * w + dx) * 4; + if (sx < 0 || sy < 0 || sx >= w || sy >= h) { + dst[j + 3] = 0; + return; + } + const i = (sy * w + sx) * 4; + dst[j] = src[i]; + dst[j + 1] = src[i + 1]; + dst[j + 2] = src[i + 2]; + dst[j + 3] = src[i + 3]; +} + +/** + * Сдвиг кадра на (dx, dy) px; пустота заполняется прозрачностью. + * dx > 0 — вправо, dy > 0 — вниз. + */ +export function shiftFrame(src, w, h, dx, dy) { + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + blit(src, out, w, h, x - dx, y - dy, x, y); + } + } + return out; +} + +/** + * Idle-кадр «парения»: тело поднимается на dy px (по умолчанию 1), + * снизу открывается прозрачность — для спрайтов с якорем по низу. + */ +export function bobFrame(src, w, h, dy = 1) { + return shiftFrame(src, w, h, 0, -dy); +} + +/** + * «Приседание»: весь спрайт уходит вниз на 1 px, нижний ряд срезается — + * спрайт становится ниже при том же якоре. Дешёвый 2-й кадр idle для + * спрайтов с полем прозрачности под ногами. + */ +export function squashFrame(src, w, h) { + return shiftFrame(src, w, h, 0, 1); +} + +/** Зеркалирование по горизонтали (кадр «вправо» → «влево»). */ +export function mirrorFrame(src, w, h) { + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + blit(src, out, w, h, w - 1 - x, y, x, y); + } + } + return out; +} + +/** + * Покачивание: nearest-поворот на angle рад вокруг точки опоры + * (по умолчанию низ-центр — «укоренённое» качание, как sway). + * pivot: { x, y } в px. + */ +export function swingFrame(src, w, h, angle, pivot) { + const px = pivot ? pivot.x : (w - 1) / 2; + const py = pivot ? pivot.y : h - 1; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + // Обратный поворот dest → src (экранные координаты, y вниз). + const dx = x - px; + const dy = y - py; + blit(src, out, w, h, Math.round(px + dx * cos + dy * sin), Math.round(py - dx * sin + dy * cos), x, y); + } + } + return out; +} + +/** + * Цикл из опорного кадра: ops — список дескрипторов, один выходной кадр на + * дескриптор; операции внутри дескриптора применяются по порядку: + * { dx?, dy?, bob?, squash?, swing?, mirror? } + * (dx/dy — сдвиг; bob — парение вверх; squash — приседание; + * swing — угол покачивания в рад; mirror — зеркалирование). + */ +export function deriveCycle(src, w, h, ops) { + return ops.map((op) => applyOps(src, w, h, op)); +} + +function applyOps(src, w, h, op) { + let frame = src; + if (op.dx || op.dy) frame = shiftFrame(frame, w, h, op.dx ?? 0, op.dy ?? 0); + if (op.bob) frame = bobFrame(frame, w, h, op.bob); + if (op.squash) frame = squashFrame(frame, w, h); + if (op.swing) frame = swingFrame(frame, w, h, op.swing, op.pivot); + if (op.mirror) frame = mirrorFrame(frame, w, h); + return frame; +} \ No newline at end of file