diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index b6b971e..bc9f797 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -12,7 +12,7 @@ import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { parseArgs, startDevServer, openGame, Checks } from './agent-lib.mjs'; +import { parseArgs, startDevServer, openGame, Checks } from './lib.mjs'; /** Корень репозитория (тулзы живут в apps/game/tools, скрипты npm — в корне). */ const ROOT = new URL('../../..', import.meta.url); diff --git a/apps/game/tools/aiart/post.mjs b/apps/game/tools/aiart/post.mjs index cfd20d9..9715d56 100644 --- a/apps/game/tools/aiart/post.mjs +++ b/apps/game/tools/aiart/post.mjs @@ -1,5 +1,6 @@ /** - * Пост-обработка AI-спрайтов «Пепельных лугов» — без зависимостей. + * Пост-обработка AI-спрайтов «Пепельных лугов» — сценарий над жанронезависимым + * @rpg/engine/tools/imaging.mjs (декодер/кроп/квантизация — там). * Запуск: node apps/game/tools/aiart/post.mjs <каталог с PNG> * → рядом ../processed/*.png (кроп по альфе, квантизация в палитру) * → ../processed/_sheet.png (обзорный лист для апрува) @@ -7,166 +8,30 @@ * AI-вывод — только сырьё: в игру попадает после ручного апрува (манифест). */ import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs'; -import { inflateSync } from 'node:zlib'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { encodePng } from '../pixelart/png.mjs'; -import { PALETTE, TRANSPARENT } from '../pixelart/palette.mjs'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; +import { normalizeToSize, quantize, reviewSheet } from '@rpg/engine/tools/imaging.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; const REVIEW = process.argv[2] ?? 'review'; const HERE = fileURLToPath(new URL('.', import.meta.url)); const OUT = join(HERE, 'processed'); const SIZE = 16; // целевой размер спрайта (арт-библия) -// ---------- декодер PNG (RGBA 8-бит, без чересстрочности) ---------- - -/** PNG-буфер -> { width, height, rgba }. Поддержан только color type 6. */ -export function decodePng(buf) { - if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('не PNG'); - let pos = 8, width = 0, height = 0, bitDepth = 0, colorType = 0; - const idat = []; - while (pos < buf.length) { - const len = buf.readUInt32BE(pos); - const type = buf.toString('ascii', pos + 4, pos + 8); - const data = buf.subarray(pos + 8, pos + 8 + len); - if (type === 'IHDR') { - width = data.readUInt32BE(0); - height = data.readUInt32BE(4); - bitDepth = data[8]; - colorType = data[9]; - } else if (type === 'IDAT') idat.push(data); - pos += 12 + len; - } - if (colorType !== 6 || bitDepth !== 8) { - throw new Error(`PNG colorType=${colorType} bitDepth=${bitDepth} — жду RGBA 8-бит`); - } - const raw = inflateSync(Buffer.concat(idat)); - const stride = width * 4; - const rgba = Buffer.alloc(height * stride); - let prev = Buffer.alloc(stride); - for (let y = 0; y < height; y++) { - const filter = raw[y * (stride + 1)]; - const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); - const cur = rgba.subarray(y * stride, (y + 1) * stride); - for (let x = 0; x < stride; x++) { - const a = x >= 4 ? cur[x - 4] : 0; - const b = prev[x]; - const c = x >= 4 ? prev[x - 4] : 0; - let v = line[x]; - if (filter === 1) v += a; - else if (filter === 2) v += b; - else if (filter === 3) v += (a + b) >> 1; - else if (filter === 4) { - const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); - v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; - } - cur[x] = v & 0xff; - } - prev = cur; - } - return { width, height, rgba }; -} - -// ---------- кроп / квантизация ---------- - -/** Габариты непрозрачного содержимого (alpha >= 128). */ -function bbox({ width, height, rgba }) { - let x0 = width, y0 = height, x1 = -1, y1 = -1; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - if (rgba[(y * width + x) * 4 + 3] >= 128) { - if (x < x0) x0 = x; - if (y < y0) y0 = y; - if (x > x1) x1 = x; - if (y > y1) y1 = y; - } - } - } - return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }; -} - -/** Пиксель (x,y) кропнутого изображения. */ -function at({ width, rgba }, b, x, y) { - const i = ((b.y + y) * width + b.x + x) * 4; - return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]; -} - -/** Кроп по альфе + nearest-масштаб до SIZE×SIZE (вписывание с центровкой). */ -export function normalizeToSize(img, size = SIZE) { - const b = bbox(img); - if (!b) return new Uint8Array(size * size * 4); // пустой спрайт - const k = Math.max(b.w / size, b.h / size); - const dw = Math.max(1, Math.round(b.w / k)); - const dh = Math.max(1, Math.round(b.h / k)); - const ox = (size - dw) >> 1, oy = (size - dh) >> 1; - const out = new Uint8Array(size * size * 4); - for (let y = 0; y < dh; y++) { - for (let x = 0; x < dw; x++) { - const sx = b.x + Math.min(b.w - 1, Math.floor((x + 0.5) * k)); - const sy = b.y + Math.min(b.h - 1, Math.floor((y + 0.5) * k)); - const src = at(img, b, sx - b.x, sy - b.y); - const i = ((oy + y) * size + (ox + x)) * 4; - out.set(src, i); - } - } - return out; -} - -/** RGB в палитре (hex -> [r,g,b]). */ -const PAL = Object.entries(PALETTE).map(([k, hex]) => { +/** Палитра игры как массив [r,g,b] для квантизации. */ +const PAL = Object.entries(PALETTE).map(([, hex]) => { const n = parseInt(hex.slice(1), 16); - return [k, (n >> 16) & 255, (n >> 8) & 255, n & 255]; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }); -/** Квантизация RGBA в палитру: тёмные пиксели -> TRANSPARENT, остальные — ближайший цвет. */ -export function quantize(rgba) { - const out = new Uint8Array(rgba.length); - for (let p = 0; p < rgba.length; p += 4) { - if (rgba[p + 3] < 128) continue; - let best = PAL[0], bd = Infinity; - for (const c of PAL) { - const d = (rgba[p] - c[1]) ** 2 + (rgba[p + 1] - c[2]) ** 2 + (rgba[p + 2] - c[3]) ** 2; - if (d < bd) { bd = d; best = c; } - } - out[p] = best[1]; out[p + 1] = best[2]; out[p + 2] = best[3]; out[p + 3] = 255; - } - return out; -} - -// ---------- обзорный лист ---------- - -/** Лист PNG: сетка спрайтов x4 с подписями имён файлов. */ -export function reviewSheet(files, path, cols = 6) { - const s = 4, pad = 6, cell = SIZE * s; - const rows = Math.ceil(files.length / cols); - const W = cols * (cell + pad) + pad; - const H = rows * (cell + pad) + pad; - const bg = Buffer.alloc(W * H * 4); - for (let p = 0; p < bg.length; p += 4) { bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; } - files.forEach(({ name, rgba }, i) => { - const cx = pad + (i % cols) * (cell + pad); - const cy = pad + Math.floor(i / cols) * (cell + pad); - for (let y = 0; y < cell; y++) { - for (let x = 0; x < cell; x++) { - const sp = ((y / s) | 0) * SIZE + ((x / s) | 0); - const si = sp * 4, di = (cy + y) * W + cx + x; - if (rgba[si + 3] === 0) continue; - bg.set(rgba.subarray(si, si + 4), di * 4); - } - } - }); - writeFileSync(path, encodePng(W, H, Buffer.from(bg))); -} - -// ---------- CLI ---------- - const names = readdirSync(join(HERE, REVIEW)).filter((f) => f.endsWith('.png')); mkdirSync(OUT, { recursive: true }); const processed = names.map((name) => { const img = decodePng(readFileSync(join(HERE, REVIEW, name))); - const rgba = quantize(normalizeToSize(img)); + const rgba = quantize(normalizeToSize(img, SIZE), PAL); writeFileSync(join(OUT, name), encodePng(SIZE, SIZE, Buffer.from(rgba))); return { name, rgba }; }); -reviewSheet(processed, join(OUT, '_sheet.png')); +reviewSheet(processed, join(OUT, '_sheet.png'), { size: SIZE }); console.log(`обработано ${processed.length} -> ${OUT}`); \ No newline at end of file diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs index dbf2848..4950426 100644 --- a/apps/game/tools/audio/gen.mjs +++ b/apps/game/tools/audio/gen.mjs @@ -5,107 +5,13 @@ * * Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком, * файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады. + * WAV-энкодер и примитивы синтеза — жанронезависимые, в @rpg/engine/tools/wav.mjs. */ import { mkdirSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { encodeWav, makeRng, synth, decay, bellPartial, bandNoise, normalize, loopify } from '@rpg/engine/tools/wav.mjs'; const OUT = fileURLToPath(new URL('../../assets/audio/', import.meta.url)); -const RATE = 22050; - -// ---------- WAV-энкодер ---------- - -/** Собрать WAV-файл из Float32 сэмплов (-1..1). */ -function encodeWav(samples) { - const n = samples.length; - const data = new ArrayBuffer(44 + n * 2); - const view = new DataView(data); - const str = (offset, s) => { - for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); - }; - str(0, 'RIFF'); - view.setUint32(4, 36 + n * 2, true); - str(8, 'WAVE'); - str(12, 'fmt '); - view.setUint32(16, 16, true); // размер fmt - view.setUint16(20, 1, true); // PCM - view.setUint16(22, 1, true); // моно - view.setUint32(24, RATE, true); - view.setUint32(28, RATE * 2, true); // байт/сек - view.setUint16(32, 2, true); // блок - view.setUint16(34, 16, true); // бит - str(36, 'data'); - view.setUint32(40, n * 2, true); - for (let i = 0; i < n; i++) { - const s = Math.max(-1, Math.min(1, samples[i])); - view.setInt16(44 + i * 2, Math.round(s * 32767), true); - } - return Buffer.from(data); -} - -// ---------- примитивы ---------- - -/** Детерминированный шум. */ -function makeRng(seed) { - let s = seed >>> 0; - return () => { - s = (s + 0x6d2b79f5) >>> 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ -function synth(seconds, fn) { - const n = Math.floor(seconds * RATE); - const out = new Float32Array(n); - for (let i = 0; i < n; i++) out[i] = fn(i / RATE, i); - return out; -} - -/** Экспоненциальный спад: полный на старте, ~0 к концу. */ -function decay(t, dur, power = 4) { - return Math.pow(Math.max(0, 1 - t / dur), power); -} - -/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ -function bellPartial(freq, stretch, dur, power) { - return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); -} - -/** Полосовой шум через однополюсные фильтры (низ + верх). */ -function bandNoise(seed, lowHz, highHz, dur) { - const r = makeRng(seed); - let l1 = 0; - let l2 = 0; - const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); - const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); - return () => { - const white = r() * 2 - 1; - l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим - l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим - return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); - }; -} - -/** Нормализовать по пику. */ -function normalize(samples, peak = 0.9) { - let max = 0; - for (const s of samples) max = Math.max(max, Math.abs(s)); - if (max === 0) return samples; - const k = peak / max; - return samples.map((s) => s * k); -} - -/** Сшить луп: кроссфейд первых fade-секунд с концом. */ -function loopify(samples, fadeSeconds = 0.5) { - const n = Math.floor(fadeSeconds * RATE); - for (let i = 0; i < n; i++) { - const k = i / n; - samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); - } - return samples.slice(0, samples.length - n); -} // ---------- звуки ---------- diff --git a/apps/game/tools/checks/agent-invariants.mjs b/apps/game/tools/checks/agent-invariants.mjs index f421d3a..ea9b5c3 100644 --- a/apps/game/tools/checks/agent-invariants.mjs +++ b/apps/game/tools/checks/agent-invariants.mjs @@ -4,7 +4,7 @@ * инварианты не содержат ошибок. * Запуск: node tools/agent.mjs run tools/checks/agent-invariants.mjs */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; +import { startDevServer, openGame, Checks } from '../lib.mjs'; export default async function ({ pretty }) { const c = new Checks('agent-invariants'); diff --git a/apps/game/tools/checks/ai.mjs b/apps/game/tools/checks/ai.mjs index 1b6fbe2..8aee97b 100644 --- a/apps/game/tools/checks/ai.mjs +++ b/apps/game/tools/checks/ai.mjs @@ -9,7 +9,7 @@ * 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты. * Запуск: node tools/agent.mjs run tools/checks/ai.mjs */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; +import { startDevServer, openGame, Checks } from '../lib.mjs'; const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); /** Тяжеловес из снапшота. */ diff --git a/apps/game/tools/checks/interact-world.mjs b/apps/game/tools/checks/interact-world.mjs index 76d3b91..33abbf5 100644 --- a/apps/game/tools/checks/interact-world.mjs +++ b/apps/game/tools/checks/interact-world.mjs @@ -9,7 +9,7 @@ * 7) инвариант interact-used-consistent чист. * Запуск: node tools/agent.mjs run tools/checks/interact-world.mjs */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; +import { startDevServer, openGame, Checks } from '../lib.mjs'; export default async function ({ pretty }) { const c = new Checks('interact-world'); diff --git a/apps/game/tools/checks/interact.mjs b/apps/game/tools/checks/interact.mjs index 91c822b..90335bc 100644 --- a/apps/game/tools/checks/interact.mjs +++ b/apps/game/tools/checks/interact.mjs @@ -8,7 +8,7 @@ * 6) лавка Милы: прилавок меняет реакцию с флагом. * Запуск: node tools/agent.mjs run tools/checks/interact.mjs */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; +import { startDevServer, openGame, Checks } from '../lib.mjs'; export default async function ({ pretty }) { const c = new Checks('interact'); diff --git a/apps/game/tools/checks/transitions.mjs b/apps/game/tools/checks/transitions.mjs index 47b74a4..ac820ef 100644 --- a/apps/game/tools/checks/transitions.mjs +++ b/apps/game/tools/checks/transitions.mjs @@ -6,7 +6,7 @@ * 4) снапшот перечисляет переходы области с триггерами. * Запуск: node tools/agent.mjs run tools/checks/transitions.mjs */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; +import { startDevServer, openGame, Checks } from '../lib.mjs'; export default async function ({ pretty }) { const c = new Checks('transitions'); diff --git a/apps/game/tools/guards/__tests__/boundary.test.mjs b/apps/game/tools/guards/__tests__/boundary.test.mjs deleted file mode 100644 index 9f7058e..0000000 --- a/apps/game/tools/guards/__tests__/boundary.test.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { checkBoundary, importSpecs } from '../boundary.mjs'; - -/** Пути в test-параметрах пишутся со слэшем, приводим к системным сепараторам. */ -const sep = process.platform === 'win32' ? '\\' : '/'; -const files = (entries) => new Map(entries.map(([f, src]) => [f.replaceAll('/', sep), src])); - -describe('importSpecs', () => { - it('вытаскивает статические, side-effect, re-export и динамические импорты', () => { - const src = [ - "import { a } from './a';", - "import './b';", - 'import * as ns from "../c/d";', - "const m = await import('./dynamic');", - "export { x } from './reexport';" - ].join('\n'); - expect(importSpecs(src)).toEqual(['./a', './b', '../c/d', './dynamic', './reexport']); - }); -}); - -describe('checkBoundary: движок', () => { - it('импорт из игры — error', () => { - const v = checkBoundary(files([ - ['packages/engine/src/core/x.ts', "import { hero } from '../../../apps/game/src/data/npcs';"] - ])); - expect(v).toEqual([ - expect.objectContaining({ severity: 'error', rule: 'engine-no-game' }) - ]); - }); - - it('относительный импорт мимо пакета — error', () => { - const v = checkBoundary(files([ - ['packages/engine/src/core/x.ts', "import { t } from '../../../CLAUDE.md';"] - ])); - expect(v[0]).toMatchObject({ rule: 'engine-self-contained', severity: 'error' }); - }); - - it('npm-импорты и внутренние относительные — чисто', () => { - const v = checkBoundary(files([ - ['packages/engine/src/render/y.ts', "import { Container } from 'pixi.js';\nimport { a } from '../core/a';"] - ])); - expect(v).toEqual([]); - }); -}); - -describe('checkBoundary: игра', () => { - it('под-путь движка мимо index.ts — error', () => { - const v = checkBoundary(files([ - ['apps/game/src/systems/z.ts', "import { iso } from '@rpg/engine/math/iso';"] - ])); - expect(v[0]).toMatchObject({ rule: 'game-engine-public-api', severity: 'error', spec: '@rpg/engine/math/iso' }); - }); - - it('публичное API разрешено: сам пакет и assets/*', () => { - const v = checkBoundary(files([ - ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"] - ])); - expect(v).toEqual([]); - }); - - it('относительный импорт в движок мимо API — error', () => { - const v = checkBoundary(files([ - ['apps/game/src/data/map.ts', "import { rleDecode } from '../../../packages/engine/src/map/mapFormat';"] - ])); - expect(v[0]).toMatchObject({ rule: 'game-self-contained', severity: 'error' }); - }); - - it('npm-импорты и внутренние относительные — чисто', () => { - const v = checkBoundary(files([ - ['apps/game/src/scenes/s.ts', "import * as PIXI from 'pixi.js';\nimport { AREAS } from '../data/locations';"] - ])); - expect(v).toEqual([]); - }); -}); \ No newline at end of file diff --git a/apps/game/tools/guards/boundary.mjs b/apps/game/tools/guards/boundary.mjs index 3f342e3..ebaa21f 100644 --- a/apps/game/tools/guards/boundary.mjs +++ b/apps/game/tools/guards/boundary.mjs @@ -1,101 +1,10 @@ /** - * Гвард границы «движок / игра». Проверяет импорты исходников: - * - * - packages/engine/src — не импортирует ничего из игры (apps/game) и - * не выходит за пределы пакета относительными импортами; - * - apps/game/src — импортирует движок только через публичное API - * (`@rpg/engine`, `@rpg/engine/assets/*`) и не выходит за пределы пакета. - * - * Запуск: node apps/game/tools/guards/boundary.mjs (npm run guard) + * Гвард границы «движок / игра» — сценарий запуска (npm run guard). + * Правила и механизм проверки — жанронезависимые, в @rpg/engine/tools/boundary.mjs. * Нарушения печатаются JSON-ом, exit 1 при наличии error-нарушений. */ -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { dirname, join, relative, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { checkBoundary, collectSources } from '@rpg/engine/tools/boundary.mjs'; -/** Корень репозитория (гвард живёт в apps/game/tools/guards — 4 уровня). */ -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); -const ENGINE = join(ROOT, 'packages', 'engine'); -const GAME = join(ROOT, 'apps', 'game'); - -/** @returns {import('node:fs').Dirent[]} файлы .ts/.tsx рекурсивно. */ -export function listSources(dir) { - const out = []; - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name); - if (e.isDirectory()) out.push(...listSources(p)); - else if (e.isFile() && /\.tsx?$/.test(e.name)) out.push(p); - } - return out; -} - -/** Спецификаторы всех статических и динамических импортов файла. */ -export function importSpecs(source) { - const specs = []; - // Тело импорта без ';', чтобы не переползти в следующий import (side-effect - // `import './b'` — без from: группа пропускается, кавычка берётся сразу). - const re = /(?:\bimport\s+(?:[^;'"]*?\sfrom\s*)?|\bexport\s+[^;'"]*?\sfrom\s+|import\s*\(\s*)(['"])([^'"]+)\1/g; - for (const m of source.matchAll(re)) specs.push(m[2]); - return specs; -} - -/** - * Чистая проверка: пары «путь файла → содержимое» (относительно корня репо, - * с системными разделителями). Возвращает нарушения: - * {severity, file, spec, rule, message}. - */ -export function checkBoundary(files) { - const out = []; - for (const [rel, source] of files) { - const pkg = rel.startsWith('packages' + sep) ? 'engine' : 'game'; - for (const spec of importSpecs(source)) { - // Движок не должен знать ничего об игре. - if (pkg === 'engine') { - if (spec.includes('apps/game') || /(^|[\\/])game([\\/]|$)/.test(spec)) { - out.push({ severity: 'error', file: rel, spec, rule: 'engine-no-game', message: 'движок импортирует игру' }); - continue; - } - if (spec.startsWith('.')) { - const target = resolve(dirname(join(ROOT, rel)), spec); - if (!target.startsWith(resolve(ENGINE) + sep)) { - out.push({ severity: 'error', file: rel, spec, rule: 'engine-self-contained', message: 'относительный импорт выходит из packages/engine' }); - } - } - continue; - } - // Игра: движок только через публичное API. - if (spec.startsWith('@rpg/engine')) { - if (spec !== '@rpg/engine' && !spec.startsWith('@rpg/engine/assets/')) { - out.push({ severity: 'error', file: rel, spec, rule: 'game-engine-public-api', message: 'под-путь движка мимо index.ts' }); - } - continue; - } - if (spec.startsWith('.')) { - const target = resolve(dirname(join(ROOT, rel)), spec); - if (!target.startsWith(resolve(GAME) + sep)) { - out.push({ severity: 'error', file: rel, spec, rule: 'game-self-contained', message: 'относительный импорт выходит из apps/game' }); - } - } - } - } - return out; -} - -/** Собрать исходники обоих пакетов: путь (относительно корня) → содержимое. */ -export function collectSources() { - const files = new Map(); - for (const dir of [join(ENGINE, 'src'), join(GAME, 'src')]) { - for (const abs of listSources(dir)) { - files.set(relative(ROOT, abs), readFileSync(abs, 'utf8')); - } - } - return files; -} - -function main() { - const violations = checkBoundary(collectSources()); - console.log(JSON.stringify({ ok: violations.length === 0, violations }, null, 2)); - process.exit(violations.length ? 1 : 0); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); \ No newline at end of file +const violations = checkBoundary(collectSources()); +console.log(JSON.stringify({ ok: violations.length === 0, violations }, null, 2)); +process.exit(violations.length ? 1 : 0); \ No newline at end of file diff --git a/apps/game/tools/lib.mjs b/apps/game/tools/lib.mjs new file mode 100644 index 0000000..57fcd6b --- /dev/null +++ b/apps/game/tools/lib.mjs @@ -0,0 +1,14 @@ +/** + * Сценарный слой тулз «Пепельных лугов» поверх жанронезависимого каркаса + * @rpg/engine/tools/agent-lib.mjs: маяк загрузки игры и re-export общих + * примитивов. Смоуки и checks/*.mjs импортируют отсюда. + */ +import { openGame as engineOpenGame } from '@rpg/engine/tools/agent-lib.mjs'; + +export { CHROMIUM, launchBrowser, startDevServer, AgentClient, Checks, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; + +/** Консольный маяк BootScene: ассеты загружены, мост готов. */ +export const BEACON = '[boot] ассеты загружены'; + +/** openGame с игровым маяком загрузки. */ +export const openGame = (opts = {}) => engineOpenGame({ beacon: BEACON, ...opts }); \ No newline at end of file diff --git a/apps/game/tools/maps/gen.test.ts b/apps/game/tools/maps/gen.test.ts index 08e11e9..3f51f72 100644 --- a/apps/game/tools/maps/gen.test.ts +++ b/apps/game/tools/maps/gen.test.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { encodeMap, parseMap } from '../../../../packages/engine/src/map/mapFormat'; +import { encodeMap, parseMap } from '@rpg/engine'; import { buildMeadowsMap, buildPondsMap, diff --git a/apps/game/tools/pixelart/canvas.mjs b/apps/game/tools/pixelart/canvas.mjs deleted file mode 100644 index 9bdb000..0000000 --- a/apps/game/tools/pixelart/canvas.mjs +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Мини-канвас с палитрой: пиксель, ромб, шум, ASCII-карты спрайтов. - */ -import { RGBA, TRANSPARENT } from './palette.mjs'; -import { encodePng } from './png.mjs'; - -export class Canvas { - constructor(width, height) { - this.width = width; - this.height = height; - this.data = Buffer.alloc(width * height * 4); - this.data.fill(0); - } - - set(x, y, color, alpha = 255) { - if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; - const i = (y * this.width + x) * 4; - if (color === null) { - this.data.fill(0, i, i + 4); - return; - } - const [r, g, b] = RGBA[color] ?? color; - this.data[i] = r; - this.data[i + 1] = g; - this.data[i + 2] = b; - this.data[i + 3] = alpha; - } - - get(x, y) { - if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null; - const i = (y * this.width + x) * 4; - if (this.data[i + 3] === 0) return null; - return [this.data[i], this.data[i + 1], this.data[i + 2], 255]; - } - - toPng() { - return encodePng(this.width, this.height, this.data); - } -} - -/** Детерминированный ГПСЧ (mulberry32). */ -export function rng(seed) { - return () => { - seed |= 0; - seed = (seed + 0x6d2b79f5) | 0; - let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** - * Залить ромб 2:1 по канвасу: base — базовый цвет; свет на верхних гранях, - * тень на нижних. light/dark — ключи палитры или null. - * Центр ромба (cx, cy), ширина w, высота h. - */ -export function fillDiamond(canvas, cx, cy, w, h, base, light = null, dark = null) { - const hw = w / 2; - const hh = h / 2; - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const rx = x - hw + 0.5; - const ry = y - hh + 0.5; - if (Math.abs(rx) / hw + Math.abs(ry) / hh <= 1) { - canvas.set(cx - hw + x, cy - hh + y, base); - } - } - } - for (let i = 0; i < hw; i++) { - // Верхний склон: от левого угла к верхней вершине (свет) и от вершины к правому углу. - const leftY = cy - Math.round((i / hw) * hh); - const rightY = cy - hh + Math.round((i / hw) * hh); - if (light) { - canvas.set(cx - hw + i, leftY, light); - canvas.set(cx + i, rightY, light); - } - // Нижний склон: от левого угла вниз к нижней вершине (тень) и от неё к правому углу. - const botLeftY = cy + Math.round((i / hw) * hh); - const botRightY = cy + hh - Math.round((i / hw) * hh); - if (dark) { - canvas.set(cx - hw + i, botLeftY, dark); - canvas.set(cx + i, botRightY, dark); - } - } -} - -/** - * ASCII-карта -> спрайт. Каждый символ — ключ палитры; '.' — прозрачный пиксель. - * Дополнительные символы передаются в extra ('x' -> 'P1' и т.п.). - */ -export function fromAscii(rows, extra = {}) { - const h = rows.length; - const w = Math.max(...rows.map((r) => r.length)); - const canvas = new Canvas(w, h); - for (let y = 0; y < h; y++) { - for (let x = 0; x < rows[y].length; x++) { - const ch = rows[y][x]; - if (ch === '.') { - continue; - } - const key = extra[ch] ?? ch; - if (!RGBA[key]) { - throw new Error(`Неизвестный символ '${ch}' в строке ${y}: ${rows[y]}`); - } - canvas.set(x, y, key); - } - } - return canvas; -} \ No newline at end of file diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs index 009f156..614f2cc 100644 --- a/apps/game/tools/pixelart/gen.mjs +++ b/apps/game/tools/pixelart/gen.mjs @@ -5,7 +5,16 @@ */ import { mkdirSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { Canvas, fillDiamond, fromAscii, rng } from './canvas.mjs'; +import { Canvas as EngineCanvas, fillDiamond, fromAscii as fromAsciiRaw, rng } from '@rpg/engine/tools/canvas.mjs'; +import { RGBA } from './palette.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)); diff --git a/apps/game/tools/pixelart/png.mjs b/apps/game/tools/pixelart/png.mjs deleted file mode 100644 index 744dbdf..0000000 --- a/apps/game/tools/pixelart/png.mjs +++ /dev/null @@ -1,61 +0,0 @@ -/** - * PNG-энкодер без зависимостей: RGBA-пиксели -> PNG-файл (zlib из node:zlib). - * Цвета — ключи палитры из palette.mjs. - */ -import { deflateSync } from 'node:zlib'; - -// CRC32 для чанков PNG. -const CRC_TABLE = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - table[n] = c >>> 0; - } - return table; -})(); - -function crc32(buf) { - let c = 0xffffffff; - for (const b of buf) { - c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); - } - return (c ^ 0xffffffff) >>> 0; -} - -function chunk(type, data) { - const len = Buffer.alloc(4); - len.writeUInt32BE(data.length); - const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(body)); - return Buffer.concat([len, body, crc]); -} - -/** rgba — Uint8Array длиной w*h*4. Возвращает PNG-буфер. */ -export function encodePng(width, height, rgba) { - // Сырые строки: байт фильтра (0 = None) + строка пикселей. - const raw = Buffer.alloc(height * (width * 4 + 1)); - for (let y = 0; y < height; y++) { - raw[y * (width * 4 + 1)] = 0; - rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); - } - - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; // bit depth - ihdr[9] = 6; // RGBA - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw, { level: 9 })), - chunk('IEND', Buffer.alloc(0)) - ]); -} \ No newline at end of file diff --git a/apps/game/tools/smoke-act1.mjs b/apps/game/tools/smoke-act1.mjs index 9010aec..0d57938 100644 --- a/apps/game/tools/smoke-act1.mjs +++ b/apps/game/tools/smoke-act1.mjs @@ -3,7 +3,7 @@ * (сбор цветов эмулируется var'ом) -> сдача -> кат-сцена с колоколом. * Запуск: node tools/smoke-act1.mjs [url] [скриншот] */ -import { openGame } from './agent-lib.mjs'; +import { openGame } from './lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_act1.png'; diff --git a/apps/game/tools/smoke-ponds.mjs b/apps/game/tools/smoke-ponds.mjs index 91e4e22..9cc0761 100644 --- a/apps/game/tools/smoke-ponds.mjs +++ b/apps/game/tools/smoke-ponds.mjs @@ -2,7 +2,7 @@ * Смоук перехода луга -> Серые пруды: герой идёт через мост к триггеру (2,2). * Запуск: node tools/smoke-ponds.mjs [url] [скриншот] */ -import { openGame } from './agent-lib.mjs'; +import { openGame } from './lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_ponds.png'; diff --git a/apps/game/tools/smoke-quest.mjs b/apps/game/tools/smoke-quest.mjs index 6c7b304..835be9e 100644 --- a/apps/game/tools/smoke-quest.mjs +++ b/apps/game/tools/smoke-quest.mjs @@ -2,7 +2,7 @@ * Смоук квестовой рамки: новая игра → Звенец → диалог с Ирвином (задание) → * сумка с журналом (I). Запуск: node tools/smoke-quest.mjs [url] [скриншот] */ -import { openGame } from './agent-lib.mjs'; +import { openGame } from './lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_quest.png'; diff --git a/apps/game/tools/smoke-zvenets.mjs b/apps/game/tools/smoke-zvenets.mjs index d518c5e..24e9ef2 100644 --- a/apps/game/tools/smoke-zvenets.mjs +++ b/apps/game/tools/smoke-zvenets.mjs @@ -2,7 +2,7 @@ * Смоук перехода луга -> Звенец: герой идёт через мост к триггеру (26,14). * Запуск: node tools/smoke-zvenets.mjs [url] [скриншот] */ -import { openGame } from './agent-lib.mjs'; +import { openGame } from './lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_zvenets.png'; diff --git a/apps/game/tools/smoke.mjs b/apps/game/tools/smoke.mjs index 3e2d55e..368b890 100644 --- a/apps/game/tools/smoke.mjs +++ b/apps/game/tools/smoke.mjs @@ -2,7 +2,7 @@ * Базовый смоук через агентный мост: новая игра, шаги, снапшот, скриншот. * Запуск: node tools/smoke.mjs [url] [файл-скриншота] */ -import { openGame } from './agent-lib.mjs'; +import { openGame } from './lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_smoke.png'; diff --git a/packages/engine/package.json b/packages/engine/package.json index 3852881..1959fc0 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -6,7 +6,8 @@ "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./assets/*": "./assets/*" + "./assets/*": "./assets/*", + "./tools/*": "./tools/*" }, "dependencies": { "pixi.js": "^8.19.0" diff --git a/packages/engine/tools/__tests__/boundary.test.mjs b/packages/engine/tools/__tests__/boundary.test.mjs new file mode 100644 index 0000000..8634366 --- /dev/null +++ b/packages/engine/tools/__tests__/boundary.test.mjs @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { checkBoundary, importSpecs } from '../boundary.mjs'; + +/** Пути в test-параметрах пишутся со слэшем, приводим к системным сепараторам. */ +const sep = process.platform === 'win32' ? '\\' : '/'; +const files = (entries) => new Map(entries.map(([f, src]) => [f.replaceAll('/', sep), src])); + +describe('importSpecs', () => { + it('вытаскивает статические, side-effect, re-export и динамические импорты', () => { + const src = [ + "import { a } from './a';", + "import './b';", + 'import * as ns from "../c/d";', + "const m = await import('./dynamic');", + "export { x } from './reexport';" + ].join('\n'); + expect(importSpecs(src)).toEqual(['./a', './b', '../c/d', './dynamic', './reexport']); + }); +}); + +describe('checkBoundary: ядро', () => { + it('импорт из приложения — error', () => { + const v = checkBoundary(files([ + ['packages/engine/src/core/x.ts', "import { hero } from '../../../apps/game/src/data/npcs';"] + ])); + expect(v).toEqual([ + expect.objectContaining({ severity: 'error', rule: 'engine-no-game' }) + ]); + }); + + it('относительный импорт мимо пакета — error', () => { + const v = checkBoundary(files([ + ['packages/engine/src/core/x.ts', "import { t } from '../../../CLAUDE.md';"] + ])); + expect(v[0]).toMatchObject({ rule: 'engine-self-contained', severity: 'error' }); + }); + + it('npm-импорты и внутренние относительные — чисто', () => { + const v = checkBoundary(files([ + ['packages/engine/src/render/y.ts', "import { Container } from 'pixi.js';\nimport { a } from '../core/a';"] + ])); + expect(v).toEqual([]); + }); +}); + +describe('checkBoundary: приложение', () => { + it('под-путь ядра мимо публичного API — error', () => { + const v = checkBoundary(files([ + ['apps/game/src/systems/z.ts', "import { iso } from '@rpg/engine/math/iso';"] + ])); + expect(v[0]).toMatchObject({ rule: 'game-engine-public-api', severity: 'error', spec: '@rpg/engine/math/iso' }); + }); + + it('публичное API разрешено: сам пакет и assets/*', () => { + const v = checkBoundary(files([ + ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"] + ])); + expect(v).toEqual([]); + }); + + it('относительный импорт в ядро мимо API — error', () => { + const v = checkBoundary(files([ + ['apps/game/src/data/map.ts', "import { rleDecode } from '../../../packages/engine/src/map/mapFormat';"] + ])); + expect(v[0]).toMatchObject({ rule: 'game-self-contained', severity: 'error' }); + }); + + it('npm-импорты и внутренние относительные — чисто', () => { + const v = checkBoundary(files([ + ['apps/game/src/scenes/s.ts', "import * as PIXI from 'pixi.js';\nimport { AREAS } from '../data/locations';"] + ])); + expect(v).toEqual([]); + }); + + it('опции расширяют публичное API (tools-под-путь)', () => { + const v = checkBoundary(files([ + ['apps/game/src/main.ts', "import { encodePng } from '@rpg/engine/tools/png.mjs';"] + ]), { publicApi: ['@rpg/engine', '@rpg/engine/assets/', '@rpg/engine/tools/'] }); + expect(v).toEqual([]); + }); + + it('файлы вне пакетов игнорируются', () => { + const v = checkBoundary(files([ + ['scripts/x.ts', "import { a } from '../../../packages/engine/src/index';"] + ])); + expect(v).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/engine/tools/agent-lib.mjs b/packages/engine/tools/agent-lib.mjs new file mode 100644 index 0000000..f580c0e --- /dev/null +++ b/packages/engine/tools/agent-lib.mjs @@ -0,0 +1,167 @@ +/** + * Библиотека агентных проверок (puppeteer-core + системный Chromium) поверх + * моста window.__agent (EngineAgent). Единственное место с браузерным + * бойлерплейтом: смоуки и сценарии строятся поверх openGame()/AgentClient. + * Контент приложения (маяк загрузки и т.п.) передаётся параметрами. + */ +import puppeteer from 'puppeteer-core'; +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; + +/** Корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */ +const ROOT = new URL('../../..', import.meta.url); + +export const CHROMIUM = '/usr/bin/chromium'; + +/** Запустить браузер с нужными headless-флагами (см. грабли autoplay-policy). */ +export async function launchBrowser() { + return puppeteer.launch({ + executablePath: CHROMIUM, + headless: true, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] + }); +} + +/** Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. */ +export async function startDevServer({ port = 5199 } = {}) { + const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { + cwd: ROOT.pathname, + stdio: ['ignore', 'pipe', 'pipe'] + }); + const url = `http://localhost:${port}/`; + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) return { url, stop: () => proc.kill('SIGTERM') }; + } catch { /* ещё не поднялся */ } + await sleep(250); + } + proc.kill('SIGTERM'); + throw new Error(`dev-сервер на :${port} не поднялся за 30 с`); +} + +/** + * Открыть приложение и дождаться готовности агента. + * newGame=true — выполнить agent.newGame() и дождаться конца fade-перехода. + * beacon — консольная строка-маяк готовности; без него ждать сразу networkidle2. + * Возвращает {browser, page, agent}. + */ +export async function openGame({ url = 'http://localhost:5199/', newGame = true, beacon = null } = {}) { + const browser = await launchBrowser(); + const page = await browser.newPage(); + await page.setViewport({ width: 960, height: 540 }); + const consoleLogs = []; + page.on('console', (m) => consoleLogs.push(m.text())); + page.on('pageerror', (e) => consoleLogs.push(`[pageerror] ${e.message}`)); + + const booted = beacon + ? new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`маяк «${beacon}» не пришёл за 30 с`)), 30000); + page.on('console', (m) => { if (m.text().includes(beacon)) { clearTimeout(t); resolve(); } }); + }) + : Promise.resolve(); + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); + await booted; + // Дождаться конца fade-перехода Boot->Menu: begin() молча отбрасывает + // команды, пришедшие во время перехода. + await sleep(1500); + + const agent = new AgentClient(page); + if (newGame) { + await agent.newGame(); + // fade-переход в локацию ещё идёт: сцена игнорирует клики до его конца. + await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + } + return { browser, page, agent, consoleLogs }; +} + +/** + * Тонкий клиент поверх window.__agent: каждое поле — прокси-вызов page.evaluate. + * snapshot()/invariants() возвращают готовые объекты; waitFor — строка-pred. + */ +export class AgentClient { + constructor(page) { this.page = page; } + + async call(method, ...args) { + return this.page.evaluate((m, a) => { + const api = window.__agent; + if (!api) return { __agentError: 'window.__agent не зарегистрирован (DEV-сборка?)' }; + return api[m](...a); + }, method, args); + } + + snapshot() { return this.call('snapshot'); } + invariants() { return this.call('invariants'); } + step(n = 1, opts) { return this.call('step', n, opts); } + waitFor(pred, opts) { return this.call('waitFor', pred, opts); } + tapTile(x, y) { return this.call('tapTile', x, y); } + tapVirtual(x, y) { return this.call('tapVirtual', x, y); } + press(action, holdTicks) { return this.call('press', action, holdTicks); } + key(code) { return this.call('key', code); } + command(name, args) { return this.call('command', name, args); } + walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); } + runDialogue() { return this.call('runDialogue'); } + newGame() { return this.call('newGame'); } + currentArea() { return this.call('currentArea'); } + + async screenshot(path) { return this.page.screenshot({ path }); } +} + +/** + * Каркас сценариев: run() ловит исключения, finish() печатает JSON и ставит код. + * Использование: const c = new Checks('имя'); await c.run('шаг', async () => ...); c.finish(); + */ +export class Checks { + constructor(name) { + this.name = name; + this.results = []; + this.started = Date.now(); + } + /** Добавить проверку; исключение/ложь → неуспех, прогон продолжается. */ + async run(checkName, fn) { + const t0 = Date.now(); + try { + const details = await fn(); + this.results.push({ name: checkName, ok: details !== false, ms: Date.now() - t0, + details: details === true ? null : details ?? null }); + } catch (err) { + this.results.push({ name: checkName, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }); + } + return this; + } + /** Ассерт-хелпер: неистинное условие кидает исключение с сообщением. */ + expect(cond, msg, details) { + if (!cond) throw new Error(msg + (details !== undefined ? `: ${JSON.stringify(details)}` : '')); + return details; + } + /** Напечатать JSON-отчёт и вернуть его (код выхода — через report.ok). */ + finish({ pretty = false } = {}) { + const report = { + name: this.name, + ok: this.results.every((r) => r.ok), + ms: Date.now() - this.started, + results: this.results + }; + console.log(pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); + return report; + } +} + +/** Разбор общих флагов CLI: --pretty/--json/--port/--only/--skip. */ +export function parseArgs(argv = process.argv.slice(2)) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--pretty' || a === '--json') out.pretty = a === '--pretty'; + else if (a === '--port') out.port = Number(argv[++i]); + else if (a === '--only') out.only = (argv[++i] ?? '').split(',').filter(Boolean); + else if (a === '--skip') out.skip = (argv[++i] ?? '').split(',').filter(Boolean); + else if (a === '--new-game') out.newGame = true; + else if (a === '--out') out.out = argv[++i]; + else if (a === '--steps') out.steps = Number(argv[++i]); + else out._.push(a); + } + return out; +} \ No newline at end of file diff --git a/packages/engine/tools/boundary.mjs b/packages/engine/tools/boundary.mjs new file mode 100644 index 0000000..bc4d286 --- /dev/null +++ b/packages/engine/tools/boundary.mjs @@ -0,0 +1,111 @@ +/** + * Гвард границы «ядро / приложение» — правила проверки импортов: + * + * - src ядра не импортирует ничего из приложения и не выходит за пределы + * пакета относительными импортами; + * - src приложения импортирует ядро только через публичное API (сам пакет + * и разрешённые под-пути) и не выходит за пределы пакета. + * + * Библиотека не знает конкретной раскладки: корни пакетов и публичное API + * передаются параметрами (с дефолтами под раскладку этого монорепо). + * Сценарий запуска для конкретного репо — у приложения. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Дефолтный корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */ +const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +/** @returns {string[]} файлы .ts/.tsx рекурсивно. */ +export function listSources(dir) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...listSources(p)); + else if (e.isFile() && /\.tsx?$/.test(e.name)) out.push(p); + } + return out; +} + +/** Спецификаторы всех статических и динамических импортов файла. */ +export function importSpecs(source) { + const specs = []; + // Тело импорта без ';', чтобы не переползти в следующий import (side-effect + // `import './b'` — без from: группа пропускается, кавычка берётся сразу). + const re = /(?:\bimport\s+(?:[^;'"]*?\sfrom\s*)?|\bexport\s+[^;'"]*?\sfrom\s+|import\s*\(\s*)(['"])([^'"]+)\1/g; + for (const m of source.matchAll(re)) specs.push(m[2]); + return specs; +} + +/** + * Опции границы: root — корень репо (ключи files — пути относительно него), + * engineDir/gameDir — корни пакетов, enginePkg — имя пакета ядра, publicApi — + * разрешённые префиксы импорта ядра из приложения. + */ +function resolveOpts({ root = DEFAULT_ROOT, engineDir = join(root, 'packages', 'engine'), + gameDir = join(root, 'apps', 'game'), enginePkg = '@rpg/engine', + publicApi = [enginePkg, `${enginePkg}/assets/`] } = {}) { + return { root, engineDir, gameDir, enginePkg, publicApi }; +} + +/** + * Чистая проверка: пары «путь файла → содержимое» (относительно root, + * с системными разделителями). Возвращает нарушения: + * {severity, file, spec, rule, message}. + */ +export function checkBoundary(files, opts = {}) { + const { root, engineDir, gameDir, enginePkg, publicApi } = resolveOpts(opts); + const engineRel = relative(root, engineDir); + const gameRel = relative(root, gameDir); + const out = []; + for (const [rel, source] of files) { + const pkg = rel.startsWith(engineRel + sep) ? 'engine' : rel.startsWith(gameRel + sep) ? 'game' : null; + if (!pkg) continue; + for (const spec of importSpecs(source)) { + // Ядро не должно знать ничего о приложении. + if (pkg === 'engine') { + if (spec.includes(gameRel) || /(^|[\\/])game([\\/]|$)/.test(spec)) { + out.push({ severity: 'error', file: rel, spec, rule: 'engine-no-game', message: 'ядро импортирует приложение' }); + continue; + } + if (spec.startsWith('.')) { + const target = resolve(dirname(join(root, rel)), spec); + if (!target.startsWith(resolve(engineDir) + sep)) { + out.push({ severity: 'error', file: rel, spec, rule: 'engine-self-contained', message: 'относительный импорт выходит из пакета ядра' }); + } + } + continue; + } + // Приложение: ядро только через публичное API (голое имя пакета — + // только само по себе, префиксы — по началу строки). + if (spec.startsWith(enginePkg)) { + const allowed = spec === enginePkg || + publicApi.some((p) => p !== enginePkg && spec.startsWith(p)); + if (!allowed) { + out.push({ severity: 'error', file: rel, spec, rule: 'game-engine-public-api', message: 'под-путь ядра мимо публичного API' }); + } + continue; + } + if (spec.startsWith('.')) { + const target = resolve(dirname(join(root, rel)), spec); + if (!target.startsWith(resolve(gameDir) + sep)) { + out.push({ severity: 'error', file: rel, spec, rule: 'game-self-contained', message: 'относительный импорт выходит из пакета приложения' }); + } + } + } + } + return out; +} + +/** Собрать исходники обоих пакетов: путь (относительно root) → содержимое. */ +export function collectSources(opts = {}) { + const { root, engineDir, gameDir } = resolveOpts(opts); + const files = new Map(); + for (const dir of [join(engineDir, 'src'), join(gameDir, 'src')]) { + for (const abs of listSources(dir)) { + files.set(relative(root, abs), readFileSync(abs, 'utf8')); + } + } + return files; +} \ No newline at end of file diff --git a/packages/engine/tools/canvas.mjs b/packages/engine/tools/canvas.mjs new file mode 100644 index 0000000..1d800f0 --- /dev/null +++ b/packages/engine/tools/canvas.mjs @@ -0,0 +1,119 @@ +/** + * Мини-канвас для пиксель-арта: пиксель, ромб 2:1, детерминированный шум, + * ASCII-карты спрайтов. Таблица цветов (ключ -> [r,g,b]) передаётся параметром — + * модуль не знает ни одной конкретной палитры. + */ +import { encodePng } from './png.mjs'; + +export class Canvas { + /** + * @param {number} width + * @param {number} height + * @param {Record} [colors] таблица цветов + * по ключам; без неё set() принимает только [r,g,b]-массивы. + */ + constructor(width, height, colors = {}) { + this.width = width; + this.height = height; + this.colors = colors; + this.data = Buffer.alloc(width * height * 4); + this.data.fill(0); + } + + /** color — ключ из таблицы цветов, [r,g,b]-массив или null (стереть). */ + set(x, y, color, alpha = 255) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + const i = (y * this.width + x) * 4; + if (color === null) { + this.data.fill(0, i, i + 4); + return; + } + const [r, g, b] = this.colors[color] ?? color; + this.data[i] = r; + this.data[i + 1] = g; + this.data[i + 2] = b; + this.data[i + 3] = alpha; + } + + /** Пиксель как [r,g,b,255] или null (вне канваса / прозрачный). */ + get(x, y) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null; + const i = (y * this.width + x) * 4; + if (this.data[i + 3] === 0) return null; + return [this.data[i], this.data[i + 1], this.data[i + 2], 255]; + } + + toPng() { + return encodePng(this.width, this.height, this.data); + } +} + +/** Детерминированный ГПСЧ (mulberry32). */ +export function rng(seed) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Залить ромб 2:1 по канвасу: base — базовый цвет; свет на верхних гранях, + * тень на нижних. Цвета — ключи палитры канваса или null. + * Центр ромба (cx, cy), ширина w, высота h. + */ +export function fillDiamond(canvas, cx, cy, w, h, base, light = null, dark = null) { + const hw = w / 2; + const hh = h / 2; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const rx = x - hw + 0.5; + const ry = y - hh + 0.5; + if (Math.abs(rx) / hw + Math.abs(ry) / hh <= 1) { + canvas.set(cx - hw + x, cy - hh + y, base); + } + } + } + for (let i = 0; i < hw; i++) { + // Верхний склон: от левого угла к верхней вершине (свет) и от вершины к правому углу. + const leftY = cy - Math.round((i / hw) * hh); + const rightY = cy - hh + Math.round((i / hw) * hh); + if (light) { + canvas.set(cx - hw + i, leftY, light); + canvas.set(cx + i, rightY, light); + } + // Нижний склон: от левого угла вниз к нижней вершине (тень) и от неё к правому углу. + const botLeftY = cy + Math.round((i / hw) * hh); + const botRightY = cy + hh - Math.round((i / hw) * hh); + if (dark) { + canvas.set(cx - hw + i, botLeftY, dark); + canvas.set(cx + i, botRightY, dark); + } + } +} + +/** + * ASCII-карта -> спрайт на новом канвасе. Каждый символ — ключ таблицы цветов; + * '.' — прозрачный пиксель; extra отображает дополнительные символы ('x' -> 'P1'). + */ +export function fromAscii(rows, colors = {}, extra = {}) { + const h = rows.length; + const w = Math.max(...rows.map((r) => r.length)); + const canvas = new Canvas(w, h, colors); + for (let y = 0; y < h; y++) { + for (let x = 0; x < rows[y].length; x++) { + const ch = rows[y][x]; + if (ch === '.') { + continue; + } + const key = extra[ch] ?? ch; + if (!colors[key]) { + throw new Error(`Неизвестный символ '${ch}' в строке ${y}: ${rows[y]}`); + } + canvas.set(x, y, key); + } + } + return canvas; +} \ No newline at end of file diff --git a/packages/engine/tools/imaging.mjs b/packages/engine/tools/imaging.mjs new file mode 100644 index 0000000..49185a9 --- /dev/null +++ b/packages/engine/tools/imaging.mjs @@ -0,0 +1,95 @@ +/** + * Обработка растровых спрайтов: кроп по альфе, nearest-масштаб, квантизация + * в заданную палитру, обзорный лист. Палитра — параметр (массив [r,g,b]); + * модуль не знает ни одной конкретной палитры. + */ +import { writeFileSync } from 'node:fs'; +import { encodePng } from './png.mjs'; + +// ---------- кроп / масштаб ---------- + +/** Габариты непрозрачного содержимого (alpha >= 128). */ +function bbox({ width, height, rgba }) { + let x0 = width, y0 = height, x1 = -1, y1 = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (rgba[(y * width + x) * 4 + 3] >= 128) { + if (x < x0) x0 = x; + if (y < y0) y0 = y; + if (x > x1) x1 = x; + if (y > y1) y1 = y; + } + } + } + return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }; +} + +/** Пиксель (x,y) кропнутого изображения. */ +function at({ width, rgba }, b, x, y) { + const i = ((b.y + y) * width + b.x + x) * 4; + return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]; +} + +/** Кроп по альфе + nearest-масштаб до size×size (вписывание с центровкой). */ +export function normalizeToSize(img, size = 16) { + const b = bbox(img); + if (!b) return new Uint8Array(size * size * 4); // пустой спрайт + const k = Math.max(b.w / size, b.h / size); + const dw = Math.max(1, Math.round(b.w / k)); + const dh = Math.max(1, Math.round(b.h / k)); + const ox = (size - dw) >> 1, oy = (size - dh) >> 1; + const out = new Uint8Array(size * size * 4); + for (let y = 0; y < dh; y++) { + for (let x = 0; x < dw; x++) { + const sx = b.x + Math.min(b.w - 1, Math.floor((x + 0.5) * k)); + const sy = b.y + Math.min(b.h - 1, Math.floor((y + 0.5) * k)); + const src = at(img, b, sx - b.x, sy - b.y); + const i = ((oy + y) * size + (ox + x)) * 4; + out.set(src, i); + } + } + return out; +} + +/** + * Квантизация RGBA в палитру (массив [r,g,b]): полупрозрачные пиксели -> + * прозрачные, остальные — ближайший по RGB цвет палитры. + */ +export function quantize(rgba, pal) { + const out = new Uint8Array(rgba.length); + for (let p = 0; p < rgba.length; p += 4) { + if (rgba[p + 3] < 128) continue; + let best = pal[0], bd = Infinity; + for (const c of pal) { + const d = (rgba[p] - c[0]) ** 2 + (rgba[p + 1] - c[1]) ** 2 + (rgba[p + 2] - c[2]) ** 2; + if (d < bd) { bd = d; best = c; } + } + out[p] = best[0]; out[p + 1] = best[1]; out[p + 2] = best[2]; out[p + 3] = 255; + } + return out; +} + +// ---------- обзорный лист ---------- + +/** Лист PNG: сетка спрайтов (scale — во сколько раз увеличить каждый). */ +export function reviewSheet(files, path, { size = 16, scale = 4, cols = 6 } = {}) { + const pad = 6, cell = size * scale; + const rows = Math.ceil(files.length / cols); + const W = cols * (cell + pad) + pad; + const H = rows * (cell + pad) + pad; + const bg = Buffer.alloc(W * H * 4); + for (let p = 0; p < bg.length; p += 4) { bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; } + files.forEach(({ rgba }, i) => { + const cx = pad + (i % cols) * (cell + pad); + const cy = pad + Math.floor(i / cols) * (cell + pad); + for (let y = 0; y < cell; y++) { + for (let x = 0; x < cell; x++) { + const sp = ((y / scale) | 0) * size + ((x / scale) | 0); + const si = sp * 4, di = (cy + y) * W + cx + x; + if (rgba[si + 3] === 0) continue; + bg.set(rgba.subarray(si, si + 4), di * 4); + } + } + }); + writeFileSync(path, encodePng(W, H, Buffer.from(bg))); +} \ No newline at end of file diff --git a/packages/engine/tools/png.mjs b/packages/engine/tools/png.mjs new file mode 100644 index 0000000..7375ecb --- /dev/null +++ b/packages/engine/tools/png.mjs @@ -0,0 +1,109 @@ +/** + * PNG-кодек без зависимостей (zlib из node:zlib): RGBA-пиксели <-> PNG-буфер. + * Энкодер пишет color type 6 (RGBA), bit depth 8, без чересстрочности; + * декодер читает ровно этот формат. + */ +import { deflateSync, inflateSync } from 'node:zlib'; + +// CRC32 для чанков PNG. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) { + c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +/** rgba — Uint8Array длиной w*h*4. Возвращает PNG-буфер. */ +export function encodePng(width, height, rgba) { + // Сырые строки: байт фильтра (0 = None) + строка пикселей. + const raw = Buffer.alloc(height * (width * 4 + 1)); + for (let y = 0; y < height; y++) { + raw[y * (width * 4 + 1)] = 0; + rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)) + ]); +} + +/** PNG-буфер -> { width, height, rgba: Buffer }. Поддержан только color type 6, 8 бит. */ +export function decodePng(buf) { + if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('не PNG'); + let pos = 8, width = 0, height = 0, bitDepth = 0, colorType = 0; + const idat = []; + while (pos < buf.length) { + const len = buf.readUInt32BE(pos); + const type = buf.toString('ascii', pos + 4, pos + 8); + const data = buf.subarray(pos + 8, pos + 8 + len); + if (type === 'IHDR') { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + } else if (type === 'IDAT') idat.push(data); + pos += 12 + len; + } + if (colorType !== 6 || bitDepth !== 8) { + throw new Error(`PNG colorType=${colorType} bitDepth=${bitDepth} — жду RGBA 8-бит`); + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 4; + const rgba = Buffer.alloc(height * stride); + let prev = Buffer.alloc(stride); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + const cur = rgba.subarray(y * stride, (y + 1) * stride); + for (let x = 0; x < stride; x++) { + const a = x >= 4 ? cur[x - 4] : 0; + const b = prev[x]; + const c = x >= 4 ? prev[x - 4] : 0; + let v = line[x]; + if (filter === 1) v += a; + else if (filter === 2) v += b; + else if (filter === 3) v += (a + b) >> 1; + else if (filter === 4) { + const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + cur[x] = v & 0xff; + } + prev = cur; + } + return { width, height, rgba }; +} \ No newline at end of file diff --git a/packages/engine/tools/wav.mjs b/packages/engine/tools/wav.mjs new file mode 100644 index 0000000..13137c5 --- /dev/null +++ b/packages/engine/tools/wav.mjs @@ -0,0 +1,106 @@ +/** + * WAV-тулкит без зависимостей: энкодер и примитивы синтеза (детерминированный + * шум, полосовой фильтр, огибающие, нормализация, кроссфейд-луп). + * Формат: моно, RATE Гц, 16-бит PCM. + */ +import { writeFileSync } from 'node:fs'; + +export const RATE = 22050; + +/** Собрать WAV-файл из Float32 сэмплов (-1..1). */ +export function encodeWav(samples, rate = RATE) { + const n = samples.length; + const data = new ArrayBuffer(44 + n * 2); + const view = new DataView(data); + const str = (offset, s) => { + for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); + }; + str(0, 'RIFF'); + view.setUint32(4, 36 + n * 2, true); + str(8, 'WAVE'); + str(12, 'fmt '); + view.setUint32(16, 16, true); // размер fmt + view.setUint16(20, 1, true); // PCM + view.setUint16(22, 1, true); // моно + view.setUint32(24, rate, true); + view.setUint32(28, rate * 2, true); // байт/сек + view.setUint16(32, 2, true); // блок + view.setUint16(34, 16, true); // бит + str(36, 'data'); + view.setUint32(40, n * 2, true); + for (let i = 0; i < n; i++) { + const s = Math.max(-1, Math.min(1, samples[i])); + view.setInt16(44 + i * 2, Math.round(s * 32767), true); + } + return Buffer.from(data); +} + +/** Записать WAV на диск (хелпер типового пайплайна «синтез -> файл»). */ +export function writeWav(path, samples, rate = RATE) { + writeFileSync(path, encodeWav(samples, rate)); +} + +// ---------- примитивы ---------- + +/** Детерминированный шум. */ +export function makeRng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ +export function synth(seconds, fn, rate = RATE) { + const n = Math.floor(seconds * rate); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = fn(i / rate, i); + return out; +} + +/** Экспоненциальный спад: полный на старте, ~0 к концу. */ +export function decay(t, dur, power = 4) { + return Math.pow(Math.max(0, 1 - t / dur), power); +} + +/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ +export function bellPartial(freq, stretch, dur, power) { + return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); +} + +/** Полосовой шум через однополюсные фильтры (низ + верх). */ +export function bandNoise(seed, lowHz, highHz, dur) { + const r = makeRng(seed); + let l1 = 0; + let l2 = 0; + const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); + const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); + return () => { + const white = r() * 2 - 1; + l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим + l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим + return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); + }; +} + +/** Нормализовать по пику. */ +export function normalize(samples, peak = 0.9) { + let max = 0; + for (const s of samples) max = Math.max(max, Math.abs(s)); + if (max === 0) return samples; + const k = peak / max; + return samples.map((s) => s * k); +} + +/** Сшить луп: кроссфейд первых fade-секунд с концом. */ +export function loopify(samples, fadeSeconds = 0.5) { + const n = Math.floor(fadeSeconds * RATE); + for (let i = 0; i < n; i++) { + const k = i / n; + samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); + } + return samples.slice(0, samples.length - n); +} \ No newline at end of file