diff --git a/docs/plan.md b/docs/plan.md index 1e586ea..16c4f00 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -41,6 +41,7 @@ | 20 | Пост-эффекты движка: переменный блюр (в точке с радиусом / на объекте / на всю сцену), хроматическая аберрация, марево («горячий воздух») | ✅ сделано (2026-09-09): движок — `render/postfx/` (`postSim.ts` чистая математика + 7 тестов, `PostFx.ts` — один кастомный Pixi-фильтр на `worldRoot`, API `set*/pulse*` по образцу Lighting, `blurObject` на вьюхе, `resolution: 'inherit'`); игра — блюр-волна резонанса, аберрация урона, авто-марево у Старой Машины/очага, мост `scene:postfx` (blur/aberration/shimmer/clear), сценарий `checks/postfx.mjs` (команды + скриншоты в пике) | | 20b | Тени без 3D: блоб-тени актёров + направленные тени tall-объектов по солнцу | ✅ сделано (2026-09-09): движок — `render/shadowSim.ts` (чистая математика солнца `sunShadow`/`blobPlacement`, 8 тестов) + `render/shadows.ts` (ступенчатая текстура блоба, раскладка); игра — `GameShadows` (ground-слой между картой и актёрами, блобы первого ребёнком вьюхи, чистка уничтоженных), сценарий `checks/shadows.mjs` (утро/полдень/вечер/ночь, скриншоты). Позже по желанию: контактный AO у подножий | | 21 | Перепаковка репо под две версии движка: v1 (черновик + демо) → `v1/` целиком, корень — диспетчер команд (`--prefix v1`); фиксы тихих поломок v1 (тени «сидят на базе», RT-тьма выводится на экран); скелет v2: `v2/` workspace, `packages/engine` (`@rpg/engine` 2.0.0-alpha, three.js), маниалы CLAUDE.md двух уровней | ✅ сделано (2026-09-09): см. «Уроки черновика» и «Направление v2» ниже; верификация — check:fast + agent:check из корня зелёные | +| 22 | Спайк v2: воксельный рендер — чистая математика вокселей (сетка, culled-меш, классический AO 0..3), `VoxelRenderer` на three.js (вершинные цвета, изо-ортокамера, dirLight+shadowMap), low-res 480×270 + nearest-апскейл, `apps/demo` со сценкой, скриншот-гейт через агента с первого дня (день/вечер, тени ползут) | ✅ сделано (2026-09-09): движок — `voxel/grid.ts` + `voxel/mesher.ts` (12 тестов) + `render/voxelRenderer.ts`; тулзы — `tools/agent-lib.mjs` (каркас v2) + `png.mjs` (RGB-скриншоты Chromium); демка — сценка в палитре арт-библии, мост `__agent.setSun/stats`; гейт `apps/demo/tools/checks/render.mjs` зелёный (тени ползут: 76k пикселей разницы утро/вечер, апскейл 2×2 без нарушений, 67 цветов). Грабля спайка: минус в Y изо-направления = камера под землёй — снапшоты зелёные, увидел только скриншотом (урок №2 подтверждён в первый же день) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 19a9f5f..1b13738 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -38,15 +38,37 @@ ```bash npm install # установка зависимостей (workspaces) npm test # юнит-тесты (Vitest) -npm run typecheck # tsc --noEmit +npm run typecheck # tsc --noEmit (движок + демка) npm run check:fast # typecheck + тесты +node apps/demo/tools/checks/render.mjs # скриншот-гейт рендера (Chromium, /tmp/v2_render_*.png) +npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` ## Структура -- `packages/engine` (`@rpg/engine`) — ядро: мир/чанки, модели, рига/анимация, - генераторы, агентный мост (появляются по мере треков). -- `apps/demo` — демо-полигон (создаётся первым треком v2). +- `packages/engine` (`@rpg/engine`) — ядро: + - `src/voxel/grid.ts` — воксельная сетка (Uint8, 0 = пусто, иначе индекс палитры); + - `src/voxel/mesher.ts` — culled-меш + классический AO 0..3 (схема 0fps, + флип диагонали), чистая математика, тестируется без рендера; + - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, + dirLight + shadow map + hemisphere, низкое разрешение (480×270); + - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser/ + startDevServer/openGame/AgentClient/Checks), swiftshader в headless; + - `tools/png.mjs` — PNG-кодек (colorType 6 и 2 — скриншоты Chromium RGB). +- `apps/demo` — демо-полигон: `src/main.ts` (сценка «пепельный луг» в палитре + арт-библии), `tools/checks/render.mjs` — скриншот-гейт: нет pageerror, меш + собран, тени ползут (кадры утро/вечер различаются), апскейл кратен 2×2, + картинка многокрасочная. Мост демки: `window.__agent.setSun(k)`/`stats()`. + +## Известные грабли (уже собранные) + +- **Знак Y в изо-направлении камеры**: вектор «target → камера» должен иметь + y = +1. С минусом камера встаёт под землю — сцена рендерится «из-под», + верхние грани без света и всё почти чёрное (снапшоты при этом зелёные — + ловится только скриншот-гейтом). +- **Скриншоты headless Chromium — RGB (colorType 2), без альфы** — декодер + `tools/png.mjs` v2 это умеет (у v1 умел только RGBA). +- **three без типов**: нужен `@types/three` в devDependencies движка. ## Рабочие привычки diff --git a/v2/apps/demo/index.html b/v2/apps/demo/index.html new file mode 100644 index 0000000..57ad407 --- /dev/null +++ b/v2/apps/demo/index.html @@ -0,0 +1,22 @@ + + + + + Демо v2 — воксельный рендер + + + + + + + \ No newline at end of file diff --git a/v2/apps/demo/package.json b/v2/apps/demo/package.json new file mode 100644 index 0000000..e5b9816 --- /dev/null +++ b/v2/apps/demo/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rpg/demo", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Демо-полигон v2: воксельная сценка под скриншот-гейтом.", + "scripts": { + "dev": "vite", + "build": "vite build", + "check:render": "node tools/checks/render.mjs" + }, + "dependencies": { + "@rpg/engine": "*", + "three": "^0.186.0" + }, + "devDependencies": { + "vite": "^6.0.0" + } +} \ No newline at end of file diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts new file mode 100644 index 0000000..87e121a --- /dev/null +++ b/v2/apps/demo/src/main.ts @@ -0,0 +1,105 @@ +/** + * Демо-полигон v2: воксельная сценка (пепельный луг) под скриншот-гейтом. + * Мир строится в воксельных единицах, проекция — только в VoxelRenderer. + * Палитра — слоты арт-библии v1 (docs/art-style.md); тёплый цвет — только жизнь. + */ +import { VoxelGrid, VoxelRenderer, meshVoxels } from '@rpg/engine'; + +// Слоты палитры арт-библии: индексы сцены → hex +const PALETTE = { + colors: { + 1: '#4a5340', // G1 — базовый луг + 2: '#343b2c', // G0 — тень травы (нижний слой) + 3: '#626c4c', // G2 — трава, свет (пятна) + 4: '#5c3b2e', // R1 — стволы/крыша тень + 5: '#7d8662', // G2H — крона, свет + 6: '#5b5b66', // P4 — камень, стены + 7: '#94949e', // P6 — свет камня + 8: '#82503a', // R2 — светлая ржавчина (крыша) + 9: '#d99a32', // B1 — цветок (жизнь!) + 10: '#44454e', // M1 — металл + }, +}; + +const S = 32; // сторона площадки + +/** Сценка: земля, деревья, дом, валун, цветы. Возвращает меш для рендера. */ +function buildScene(): ReturnType { + const g = new VoxelGrid(S, 10, S); + // земля: два слоя, пятна светлой травы сверху + for (let x = 0; x < S; x++) + for (let z = 0; z < S; z++) { + g.set(x, 0, z, 2); + const patch = (x * 7 + z * 13) % 11 < 3; // детерминированные пятна + g.set(x, 1, z, patch ? 3 : 1); + } + for (const [tx, tz, h] of [[6, 8, 4], [24, 12, 5], [10, 25, 4], [26, 26, 4]] as const) { + tree(g, tx, tz, h); + } + house(g, 15, 6); + boulder(g, 22, 21); + for (const [fx, fz] of [[9, 17], [13, 21], [20, 16], [27, 9]] as const) { + g.set(fx, 2, fz, 9); // цветок — единственный тёплый цвет + } + return meshVoxels(g, PALETTE); +} + +/** Дерево: ствол + двухслойная крона со светлым верхом. */ +function tree(g: VoxelGrid, x: number, z: number, h: number): void { + for (let y = 2; y < 2 + h; y++) g.set(x, y, z, 4); + const top = 2 + h; + for (let dy = 0; dy < 2; dy++) + for (let dx = -1; dx <= 1; dx++) + for (let dz = -1; dz <= 1; dz++) { + g.set(x + dx, top + dy, z + dz, dy === 0 ? 3 : 5); // низ кроны темнее + } + g.set(x, top + 2, z, 5); +} + +/** Дом: стены P4 со светлыми пилястрами, двускатная крыша R2. */ +function house(g: VoxelGrid, x: number, z: number): void { + const w = 6, d = 5, hWall = 3; + for (let i = 0; i < w; i++) + for (let j = 0; j < d; j++) { + const wall = i === 0 || j === 0 || i === w - 1 || j === d - 1; + for (let y = 2; y < 2 + hWall; y++) { + if (wall) g.set(x + i, y, z + j, (i + j) % 3 === 0 ? 7 : 6); + else g.set(x + i, y, z + j, 10); // интерьер темнее (виден сквозь проёмы) + } + } + // дверной проём с юга + for (let y = 2; y < 4; y++) g.set(x + 3, y, z, 0); + // крыша: три сужающиеся ступени (скат вверх к коньку) + for (let t = 0; t < 3; t++) { + const j0 = z + t, j1 = z + d - 1 - t; + for (let i = 0; i < w; i++) + for (let j = j0; j <= j1; j++) g.set(x + i, 2 + hWall + t, j, t === 2 ? 4 : 8); + } +} + +/** Валун: неровная глыба, свет сверху. */ +function boulder(g: VoxelGrid, x: number, z: number): void { + const blobs = [[0, 0, 0], [1, 0, 0], [0, 0, 1], [1, 0, 1], [0, 1, 0], [1, 1, 0]]; + for (const [dx, dy, dz] of blobs) g.set(x + dx, 2 + dy, z + dz, dy ? 7 : 6); +} + +const mesh = buildScene(); +const canvas = document.getElementById('scene') as HTMLCanvasElement; +const renderer = new VoxelRenderer(canvas, { target: [S / 2, 2, S / 2], viewSize: 15 }); +renderer.addMesh(mesh); + +// Агентный мост (только DEV-поведение): солнце + статистика меша +let sunK = 0.5; +(window as unknown as Record).__agent = { + setSun(k: number): void { sunK = k; renderer.setSun(k); }, + stats(): { vertices: number; triangles: number; sun: number } { + return { vertices: mesh.vertexCount, triangles: mesh.triangleCount, sun: sunK }; + }, +}; + +function frame(): void { + renderer.render(); + requestAnimationFrame(frame); +} +frame(); +(window as unknown as Record).__beacon = true; \ No newline at end of file diff --git a/v2/apps/demo/tools/checks/render.mjs b/v2/apps/demo/tools/checks/render.mjs new file mode 100644 index 0000000..9007789 --- /dev/null +++ b/v2/apps/demo/tools/checks/render.mjs @@ -0,0 +1,100 @@ +/** + * Скриншот-гейт рендера v2 (урок №2: визуальная подсистема готова только с ним). + * Проверяет в реальном Chromium (swiftshader): страница без ошибок, меш собран, + * солнце едет (тени ползут — кадры различаются), пиксель-арт апскейл кратен 2×2, + * картинка многокрасочная. Скриншоты: /tmp/v2_render_*.png. + * + * Запуск: node apps/demo/tools/checks/render.mjs [--port 5299] + */ +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { sleep, launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5299); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'render', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + + const errs = client.errors(); + check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + + const stats = await client.call('stats'); + check('меш собран', !!stats && stats.triangles > 1000 && stats.vertices > 1000, stats); + + // День и вечер: солнце движется → тени ползут + await client.call('setSun', 0.08); + await sleep(500); + const morningShot = await client.screenshot('/tmp/v2_render_morning.png'); + await client.call('setSun', 0.9); + await sleep(500); + const eveningShot = await client.screenshot('/tmp/v2_render_evening.png'); + + const morning = decodePng(readFileSync(morningShot)); + const evening = decodePng(readFileSync(eveningShot)); + const diff = countDiff(morning.rgba, evening.rgba); + check('тени ползут: кадры утро/вечер различаются', diff > 20000, { diffPixels: diff }); + + const block = pixelBlockViolations(morning); + check('апскейл кратен 2×2 (CSS целый масштаб)', block.violations / block.samples < 0.01, block); + + const colors = distinctColors(morning.rgba); + check('картинка многокрасочная (палитра × AO × свет)', colors > 24, { colors }); + + report.ok = report.checks.every((c) => c.ok); + } finally { + await browser.close(); + } +} catch (e) { + check('прогон без исключений', false, String(e)); +} finally { + server.stop(); +} + +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); + +/** Число различающихся RGBA-пикселей двух кадров одного размера. */ +function countDiff(a, b) { + let n = 0; + for (let i = 0; i < a.length; i += 4) { + if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++; + } + return n; +} + +/** Нарушения кратности 2×2: соседние пиксели блока должны совпадать. */ +function pixelBlockViolations(png) { + const { width, height, rgba } = png; + let violations = 0, samples = 0; + const eq = (x1, y1, x2, y2) => { + const i1 = (y1 * width + x1) * 4, i2 = (y2 * width + x2) * 4; + return Math.abs(rgba[i1] - rgba[i2]) <= 2 && Math.abs(rgba[i1 + 1] - rgba[i2 + 1]) <= 2 && + Math.abs(rgba[i1 + 2] - rgba[i2 + 2]) <= 2; + }; + for (let y = 0; y + 1 < height; y += 2) + for (let x = 0; x + 1 < width; x += 2) { + samples++; + if (!eq(x, y, x + 1, y) || !eq(x, y, x, y + 1)) violations++; + } + return { violations, samples }; +} + +/** Число уникальных RGB-цветов (квант 8). */ +function distinctColors(rgba) { + const set = new Set(); + for (let i = 0; i < rgba.length; i += 4) set.add((rgba[i] >> 3) + ',' + (rgba[i + 1] >> 3) + ',' + (rgba[i + 2] >> 3)); + return set.size; +} \ No newline at end of file diff --git a/v2/apps/demo/tsconfig.json b/v2/apps/demo/tsconfig.json new file mode 100644 index 0000000..9e6cc82 --- /dev/null +++ b/v2/apps/demo/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["src", "tools"] +} \ No newline at end of file diff --git a/v2/apps/demo/vite.config.ts b/v2/apps/demo/vite.config.ts new file mode 100644 index 0000000..63060fa --- /dev/null +++ b/v2/apps/demo/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + resolve: { + alias: [ + // только regex-алиас (грабля v1): строковый перехватывает и под-пути + { find: /^@rpg\/engine$/, replacement: fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url)) }, + ], + }, +}); \ No newline at end of file diff --git a/v2/package-lock.json b/v2/package-lock.json index cf17472..c2dae37 100644 --- a/v2/package-lock.json +++ b/v2/package-lock.json @@ -12,6 +12,532 @@ "apps/*" ] }, + "apps/demo": { + "name": "@rpg/demo", + "version": "0.0.1", + "dependencies": { + "@rpg/engine": "*", + "three": "^0.186.0" + }, + "devDependencies": { + "vite": "^6.0.0" + } + }, + "apps/demo/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "apps/demo/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "apps/demo/node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -301,6 +827,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -318,6 +861,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -335,6 +895,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -430,6 +1007,35 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -819,10 +1425,21 @@ "win32" ] }, + "node_modules/@rpg/demo": { + "resolved": "apps/demo", + "link": true + }, "node_modules/@rpg/engine": { "resolved": "packages/engine", "link": true }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -830,6 +1447,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -943,6 +1589,32 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -990,6 +1662,56 @@ "node": ">= 16" } }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1018,6 +1740,20 @@ "node": ">=6" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1064,6 +1800,16 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1084,6 +1830,31 @@ "node": ">=12.0.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1099,6 +1870,29 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1116,6 +1910,30 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", + "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1166,6 +1984,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1195,6 +2026,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/puppeteer-core": { + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", + "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1272,6 +2121,39 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/three": { "version": "0.186.0", "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", @@ -1292,6 +2174,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -1322,6 +2221,13 @@ "node": ">=14.0.0" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1485,6 +2391,13 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1502,6 +2415,112 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/engine": { "name": "@rpg/engine", "version": "2.0.0-alpha.0", @@ -1509,6 +2528,8 @@ "three": "^0.186.0" }, "devDependencies": { + "@types/three": "^0.185.4", + "puppeteer-core": "^25.10.0", "typescript": "^5.6.0", "vitest": "^2.1.0" } diff --git a/v2/package.json b/v2/package.json index fddfd94..01ef204 100644 --- a/v2/package.json +++ b/v2/package.json @@ -11,7 +11,7 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit -p packages/engine", + "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/demo", "check:fast": "npm run typecheck && npm test" } } \ No newline at end of file diff --git a/v2/packages/engine/package.json b/v2/packages/engine/package.json index 66fe20b..351cf75 100644 --- a/v2/packages/engine/package.json +++ b/v2/packages/engine/package.json @@ -11,7 +11,9 @@ "three": "^0.186.0" }, "devDependencies": { + "@types/three": "^0.185.4", + "puppeteer-core": "^25.10.0", "typescript": "^5.6.0", "vitest": "^2.1.0" } -} \ No newline at end of file +} diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index d1e4bb4..e288f31 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -13,4 +13,11 @@ */ /** Версия движка v2 (синхронизируется с package.json). */ -export const ENGINE_VERSION = '2.0.0-alpha.0'; \ No newline at end of file +export const ENGINE_VERSION = '2.0.0-alpha.0'; + +export { VoxelGrid, hexToRgb } from './voxel/grid'; +export type { VoxelPalette } from './voxel/grid'; +export { meshVoxels } from './voxel/mesher'; +export type { VoxelMesh, MeshOptions } from './voxel/mesher'; +export { VoxelRenderer } from './render/voxelRenderer'; +export type { VoxelRendererOptions } from './render/voxelRenderer'; \ No newline at end of file diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts new file mode 100644 index 0000000..258dc8e --- /dev/null +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -0,0 +1,132 @@ +/** + * VoxelRenderer — three.js как низкоуровневый рендер-бэкенд (решение v2). + * + * Граница рендера: мир живёт в воксельных единицах, проекция происходит + * только здесь (урок v1 №1). Низкое разрешение бэкинг-стора + CSS-целый + * масштаб с image-rendering: pixelated даёт пиксель-арт взгляд (продолжение + * стиля v1). AO запечён в вершинные цвета мешером; свет — dirLight с + * shadow map + мягкий ambient, чтобы тени не проваливались в чёрное. + */ +import * as THREE from 'three'; +import type { VoxelMesh } from '../voxel/mesher'; + +export interface VoxelRendererOptions { + /** низкое разрешение рендера (пиксель-арт), по умолчанию 480×270 */ + width?: number; + height?: number; + /** центр ортокамеры в мировых единицах */ + target?: [number, number, number]; + /** половина высоты орто-фрустума (зум), в мировых единицах */ + viewSize?: number; +} + +/** Изометрия демо: диметрия 2:1 — yaw 45°, тангенс угла возвышения 1/2. */ +const ISO_YAW = Math.PI / 4; +const ISO_TAN = 0.5; + +export class VoxelRenderer { + readonly renderer: THREE.WebGLRenderer; + readonly scene: THREE.Scene; + readonly camera: THREE.OrthographicCamera; + private readonly sun: THREE.DirectionalLight; + private readonly ambient: THREE.HemisphereLight; + private readonly target: THREE.Vector3; + private readonly viewSize: number; + + constructor(canvas: HTMLCanvasElement, opts: VoxelRendererOptions = {}) { + const width = opts.width ?? 480; + const height = opts.height ?? 270; + this.target = new THREE.Vector3(...(opts.target ?? [0, 0, 0])); + this.viewSize = opts.viewSize ?? 16; + + this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false }); + this.renderer.setSize(width, height, false); // CSS-размер задаёт страница + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFShadowMap; + + this.scene = new THREE.Scene(); + + this.camera = this.makeCamera(width, height); + + this.ambient = new THREE.HemisphereLight(0x94949e, 0x343b2c, 0.55); // P5 / G0 + this.scene.add(this.ambient); + + this.sun = new THREE.DirectionalLight(0xffffff, 1.4); + this.sun.castShadow = true; + this.sun.shadow.mapSize.set(1024, 1024); + const r = this.viewSize * 1.2; + this.sun.shadow.camera.left = -r; + this.sun.shadow.camera.right = r; + this.sun.shadow.camera.top = r; + this.sun.shadow.camera.bottom = -r; + this.sun.shadow.bias = -0.0005; + this.scene.add(this.sun); + this.scene.add(this.sun.target); + this.setSun(0.5); + } + + /** Материал вокселей: вершинные цвета (палитра × AO) + ламберт от солнца. */ + static material(): THREE.MeshLambertMaterial { + return new THREE.MeshLambertMaterial({ vertexColors: true }); + } + + /** Добавляет меш из MeshData мешера в сцену (тени: cast + receive). */ + addMesh(mesh: VoxelMesh): THREE.Mesh { + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3)); + geo.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3)); + geo.setAttribute('color', new THREE.Float32BufferAttribute(mesh.colors, 3)); + geo.setIndex(mesh.indices); + const meshObj = new THREE.Mesh(geo, VoxelRenderer.material()); + meshObj.castShadow = true; + meshObj.receiveShadow = true; + this.scene.add(meshObj); + return meshObj; + } + + /** + * Солнце дня: k — 0 рассвет → 0.5 полдень → 1 закат. Азимут едет по + * мировой горизонтали (восток→запад), высота — по синусу дня; цвет теплеет + * у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт. + */ + setSun(k: number): void { + const kk = Math.min(1, Math.max(0, k)); + const az = Math.PI * (1 - kk); // 180°→0°: восток → юг → запад + const elev = Math.sin(kk * Math.PI) * (Math.PI / 3) + 0.12; // не ниже ~7° + const dist = this.viewSize * 3; + this.sun.position.set( + this.target.x + dist * Math.cos(az) * Math.cos(elev), + this.target.y + dist * Math.sin(elev), + this.target.z + dist * Math.sin(az) * Math.cos(elev), + ); + this.sun.target.position.copy(this.target); + this.sun.intensity = 0.4 + 1.1 * Math.sin(kk * Math.PI); + // тёплый свет у горизонта (F1), нейтральный в полдень + const warm = 1 - Math.sin(kk * Math.PI); + this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45); + } + + /** Один кадр. */ + render(): void { + this.camera.updateMatrixWorld(); + this.renderer.render(this.scene, this.camera); + } + + /** Ортокамера диметрии 2:1, смотрит в target с изо-направления. */ + private makeCamera(width: number, height: number): THREE.OrthographicCamera { + const aspect = width / height; + const half = this.viewSize; + const cam = new THREE.OrthographicCamera( + -half * aspect, half * aspect, half, -half, 0.1, this.viewSize * 10, + ); + const dir = new THREE.Vector3( + Math.cos(ISO_YAW) * ISO_TAN, + 1, // камера НАД сценой (минус давал вид из-под земли: верхние грани без света) + Math.sin(ISO_YAW) * ISO_TAN, + ).normalize(); + const dist = this.viewSize * 4; + cam.position.copy(this.target).addScaledVector(dir, dist); + cam.lookAt(this.target); + return cam; + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/voxel/__tests__/mesher.test.ts b/v2/packages/engine/src/voxel/__tests__/mesher.test.ts new file mode 100644 index 0000000..b47db14 --- /dev/null +++ b/v2/packages/engine/src/voxel/__tests__/mesher.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { VoxelGrid, hexToRgb } from '../grid'; +import { meshVoxels } from '../mesher'; + +const PALETTE = { colors: { 1: '#808080', 2: '#00ff00' } }; + +describe('VoxelGrid', () => { + it('set/get и границы: вне сетки всегда пусто', () => { + const g = new VoxelGrid(2, 3, 4); + g.set(1, 2, 3, 7); + expect(g.get(1, 2, 3)).toBe(7); + expect(g.get(2, 2, 3)).toBe(0); + expect(g.get(-1, 0, 0)).toBe(0); + g.set(100, 0, 0, 5); // игнорируется + expect(g.count()).toBe(1); + }); + + it('set валидирует цвет, конструктор — размеры', () => { + const g = new VoxelGrid(1, 1, 1); + expect(() => g.set(0, 0, 0, 256)).toThrow(); + expect(() => new VoxelGrid(0, 1, 1)).toThrow(); + }); + + it('hexToRgb: короткая и полная формы, ошибка на мусоре', () => { + expect(hexToRgb('#f00')).toEqual([1, 0, 0]); + expect(hexToRgb('#0080ff')).toEqual([0, 128 / 255, 1]); + expect(() => hexToRgb('#zzz')).toThrow(); + }); +}); + +describe('meshVoxels — culled-меш', () => { + it('один воксель: 6 граней, 24 вершины, 12 треугольников, все нормали по осям', () => { + const g = new VoxelGrid(1, 1, 1); + g.set(0, 0, 0, 1); + const m = meshVoxels(g, PALETTE); + expect(m.vertexCount).toBe(24); + expect(m.triangleCount).toBe(12); + // все 6 осевых направлений присутствуют (нормали не смешанные) + const dirs = new Set(); + for (let v = 0; v < m.vertexCount; v++) { + dirs.add(`${m.normals[v * 3]},${m.normals[v * 3 + 1]},${m.normals[v * 3 + 2]}`); + } + expect(dirs.size).toBe(6); + }); + + it('winding согласован с нормалью: cross(v1-v0, v2-v0) ≈ normal (для three.js)', () => { + const g = new VoxelGrid(2, 2, 2); + for (let x = 0; x < 2; x++) for (let y = 0; y < 2; y++) g.set(x, y, 0, 1), g.set(x, y, 1, 1); + const m = meshVoxels(g, PALETTE); + for (let t = 0; t < m.indices.length; t += 3) { + const i = [m.indices[t], m.indices[t + 1], m.indices[t + 2]]; + const p = i.map((k) => [m.positions[k * 3], m.positions[k * 3 + 1], m.positions[k * 3 + 2]]); + const e1 = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]]; + const e2 = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]]; + const cr = [ + e1[1] * e2[2] - e1[2] * e2[1], + e1[2] * e2[0] - e1[0] * e2[2], + e1[0] * e2[1] - e1[1] * e2[0], + ]; + const n = i.map((k) => m.normals[k * 3]); + void n; + // осевые компоненты нормали совпадают с компонентами cross + for (let a = 0; a < 3; a++) { + const axisVal = m.normals[i[0] * 3 + a]; + if (axisVal !== 0) expect(cr[a]).toBeCloseTo(axisVal, 5); + } + } + }); + + it('внутренние грани сплошного блока срезаны: поверхность 6×9 граней', () => { + const g = new VoxelGrid(3, 3, 3); + for (let x = 0; x < 3; x++) + for (let y = 0; y < 3; y++) for (let z = 0; z < 3; z++) g.set(x, y, z, 1); + const m = meshVoxels(g, PALETTE); + expect(m.vertexCount).toBe(6 * 9 * 4); + }); + + it('соседние воксели не выпускают общую грань', () => { + const g = new VoxelGrid(2, 1, 1); + g.set(0, 0, 0, 1); + g.set(1, 0, 0, 1); + const m = meshVoxels(g, PALETTE); + expect(m.vertexCount).toBe(10 * 4); // 12 граней − 1 скрытая + }); +}); + +describe('meshVoxels — AO', () => { + it('вершина, зажатая двумя боковыми соседями, максимально темная (ao=0)', () => { + const g = new VoxelGrid(3, 3, 3); + g.set(1, 1, 1, 1); // исследуемый воксель + g.set(2, 2, 1, 1); // сосед сбоку по y+1 + g.set(2, 1, 0, 1); // сосед сбоку по z-1 + const m = meshVoxels(g, PALETTE); + const i0 = m.ao.indexOf(0); + expect(i0).toBeGreaterThanOrEqual(0); + // вершина с ao=0 лежит в углу (2,2,1)-грани +X: позиция (2,2,1) + expect(m.positions[i0 * 3]).toBeCloseTo(2); + expect(m.positions[i0 * 3 + 1]).toBeCloseTo(2); + expect(m.positions[i0 * 3 + 2]).toBeCloseTo(1); + }); + + it('AO затемняет землю рядом со стоящим блоком (ao=2 у стыка)', () => { + const g = new VoxelGrid(3, 2, 1); + g.set(0, 0, 0, 1); + g.set(2, 0, 0, 1); // земля + g.set(1, 1, 0, 1); // блок на земле + const m = meshVoxels(g, PALETTE); + const dark = m.ao.filter((a) => a === 2).length; + expect(dark).toBeGreaterThan(0); + // вся AO в допустимом диапазоне + for (const a of m.ao) expect(a).toBeGreaterThanOrEqual(0), expect(a).toBeLessThanOrEqual(3); + }); + + it('цвет запекается с яркостью AO: тёмная вершина темнее цвета палитры', () => { + const g = new VoxelGrid(3, 3, 3); + g.set(1, 1, 1, 1); + g.set(2, 2, 1, 1); + g.set(2, 1, 0, 1); + const m = meshVoxels(g, PALETTE); + const i0 = m.ao.indexOf(0); + expect(m.colors[i0 * 3]).toBeCloseTo(0x80 / 255 * 0.45, 3); + const bright = m.ao.indexOf(3); + expect(m.colors[bright * 3]).toBeCloseTo(0x80 / 255, 3); + }); + + it('флип диагонали: у стыка с тёмными углами треугольники идут через тёмную пару', () => { + // Сетка, где у грани +X вокселя (1,1,1) сумма AO порядка (0,3) > (3,2) — флип включается + const g = new VoxelGrid(4, 4, 4); + g.set(1, 1, 1, 1); + g.set(2, 2, 1, 1); + g.set(2, 1, 0, 1); + const m = meshVoxels(g, PALETTE); + // ищем квадроугольник грани +X: вершины (2,1,1),(2,2,1),(2,2,2),(2,1,2) + const want = [[2, 1, 1], [2, 2, 1], [2, 2, 2], [2, 1, 2]]; + const s = findQuadStart(m, want); + expect(s).toBeGreaterThanOrEqual(0); + expect(findQuadIndices(m, s)).toEqual([s + 1, s + 2, s + 3, s + 1, s + 3, s]); + }); + + it('без стыка диагональ дефолтная (v0-v2)', () => { + const g = new VoxelGrid(2, 1, 1); + g.set(0, 0, 0, 1); + const m = meshVoxels(g, PALETTE); + // грань +X вокселя (0,0,0): вершины (1,0,0),(1,1,0),(1,1,1),(1,0,1) + const s = findQuadStart(m, [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]]); + expect(s).toBeGreaterThanOrEqual(0); + expect(findQuadIndices(m, s)).toEqual([s, s + 1, s + 2, s, s + 2, s + 3]); + }); +}); + +/** Ищет стартовый индекс вершины квадроугольника с заданными позициями. */ +function findQuadStart(m: ReturnType, want: number[][]): number { + for (let v = 0; v + 3 < m.vertexCount; v += 4) { + let ok = true; + for (let k = 0; k < 4 && ok; k++) { + for (let a = 0; a < 3; a++) { + if (Math.abs(m.positions[(v + k) * 3 + a] - want[k][a]) > 1e-6) ok = false; + } + } + if (ok) return v; + } + return -1; +} + +/** Ищет в массиве индексов пару треугольников квадроугольника с вершинами s..s+3. */ +function findQuadIndices(m: ReturnType, s: number): number[] { + const def = [s, s + 1, s + 2, s, s + 2, s + 3]; + const flip = [s + 1, s + 2, s + 3, s + 1, s + 3, s]; + for (let i = 0; i + 5 < m.indices.length; i++) { + const seg = m.indices.slice(i, i + 6); + if (seg.join(',') === def.join(',')) return def; + if (seg.join(',') === flip.join(',')) return flip; + } + return []; +} \ No newline at end of file diff --git a/v2/packages/engine/src/voxel/grid.ts b/v2/packages/engine/src/voxel/grid.ts new file mode 100644 index 0000000..bb31c95 --- /dev/null +++ b/v2/packages/engine/src/voxel/grid.ts @@ -0,0 +1,72 @@ +/** + * Воксельная сетка — чистая математика, без зависимостей (урок v1 №3). + * + * Значение вокселя — индекс цвета в палитре сцены (1..255); 0 — пустота. + * Сетка фиксированного размера, координаты — целые мировые единицы (1 = размер + * вокселя). Никакой проекции тут нет: проекция — на границе рендера (урок №1). + */ + +export interface VoxelPalette { + /** индекс → цвет в hex (#rrggbb); индекс 0 (пустота) не используется */ + colors: Record; +} + +export class VoxelGrid { + readonly sx: number; + readonly sy: number; + readonly sz: number; + /** плоский буфер: index = x + sx*(y + sy*z); 0 — пусто, иначе цвет 1..255 */ + readonly data: Uint8Array; + + constructor(sx: number, sy: number, sz: number) { + if (sx <= 0 || sy <= 0 || sz <= 0) { + throw new Error(`VoxelGrid: размеры должны быть положительными, дано ${sx}×${sy}×${sz}`); + } + this.sx = sx; + this.sy = sy; + this.sz = sz; + this.data = new Uint8Array(sx * sy * sz); + } + + idx(x: number, y: number, z: number): number { + return x + this.sx * (y + this.sy * z); + } + + /** Внутри ли координата сетки. */ + inside(x: number, y: number, z: number): boolean { + return x >= 0 && y >= 0 && z >= 0 && x < this.sx && y < this.sy && z < this.sz; + } + + /** Цвет вокселя (0 — пусто). Вне сетки всегда 0. */ + get(x: number, y: number, z: number): number { + if (!this.inside(x, y, z)) return 0; + return this.data[this.idx(x, y, z)]; + } + + /** Поставить воксель (0 — стереть). Игнорирует выход за границы. */ + set(x: number, y: number, z: number, color: number): void { + if (!this.inside(x, y, z)) return; + if (color < 0 || color > 255) throw new Error(`VoxelGrid.set: цвет ${color} вне 0..255`); + this.data[this.idx(x, y, z)] = color; + } + + isEmpty(x: number, y: number, z: number): boolean { + return this.get(x, y, z) === 0; + } + + /** Число непустых вокселей (для тестов и статистики). */ + count(): number { + let n = 0; + for (let i = 0; i < this.data.length; i++) if (this.data[i] !== 0) n++; + return n; + } +} + +/** Парсинг hex-цвета (#rgb или #rrggbb) в [r,g,b] 0..1. */ +export function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + const s = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; + const n = parseInt(s, 16); + if (Number.isNaN(n) || s.length !== 6) throw new Error(`hexToRgb: плохой цвет "${hex}"`); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} \ No newline at end of file diff --git a/v2/packages/engine/src/voxel/mesher.ts b/v2/packages/engine/src/voxel/mesher.ts new file mode 100644 index 0000000..fcf2286 --- /dev/null +++ b/v2/packages/engine/src/voxel/mesher.ts @@ -0,0 +1,133 @@ +/** + * Воксельный мешер — чистая математика (урок v1 №3), three.js тут не знает. + * + * Culled-меш: для каждого сплошного вокселя выпускаются только грани к пустоте. + * На каждую вершину грани — классический воксельный AO (0..3) по трём соседям + * (side1/side2/corner, схема 0fps) с флипом диагонали квадроугольника против + * анизотропных артефактов. AO запекается в вершинные цвета. + */ +import { VoxelGrid, hexToRgb } from './grid'; +import type { VoxelPalette } from './grid'; + +export interface VoxelMesh { + /** xyz по тройкам, в единицах вокселей (углы 0..1 локально) */ + positions: number[]; + /** нормали граней (для dirLight и теней) */ + normals: number[]; + /** rgb по тройкам — цвет палитры × яркость AO */ + colors: number[]; + indices: number[]; + /** AO 0..3 на вершину (для тестов и отладки) */ + ao: number[]; + /** размер партий: вершин и треугольников (статистика) */ + vertexCount: number; + triangleCount: number; +} + +export interface MeshOptions { + /** яркость по уровню AO (индекс = уровень occlusion 0..3); [1.0 — открыто] */ + aoLevels?: [number, number, number, number]; +} + +const AO_LEVELS_DEFAULT: [number, number, number, number] = [0.45, 0.65, 0.85, 1.0]; + +/** Классический AO по трём соседям (1 — занято, 0 — пусто). */ +function cornerAo(side1: number, side2: number, corner: number): number { + if (side1 && side2) return 0; // щель закрыта с двух сторон — самый тёмный + return 3 - (side1 + side2 + corner); +} + +/** Одна грань: ось a (0/1/2), знак s, порядок вершин зависит от знака. */ +const FACES: Array<{ axis: 0 | 1 | 2; sign: 1 | -1 }> = [ + { axis: 0, sign: 1 }, { axis: 0, sign: -1 }, + { axis: 1, sign: 1 }, { axis: 1, sign: -1 }, + { axis: 2, sign: 1 }, { axis: 2, sign: -1 }, +]; + +/** Вершины квадроугольника в (u,v) на касательных осях, CCW снаружи. */ +const ORDER_POS: Array<[number, number]> = [[0, 0], [1, 0], [1, 1], [0, 1]]; +const ORDER_NEG: Array<[number, number]> = [[0, 0], [0, 1], [1, 1], [1, 0]]; + +/** + * Строит culled-меш сетки. Сосед вне сетки считается пустым (границы мира + * видны), поэтому открытые срезы не исчезают. + */ +export function meshVoxels(grid: VoxelGrid, palette: VoxelPalette, opts: MeshOptions = {}): VoxelMesh { + const levels = opts.aoLevels ?? AO_LEVELS_DEFAULT; + const rgbCache = new Map(); + const rgb = (color: number): [number, number, number] => { + let c = rgbCache.get(color); + if (!c) { + const hex = palette.colors[color]; + if (!hex) throw new Error(`meshVoxels: цвет ${color} не задан в палитре`); + c = hexToRgb(hex); + rgbCache.set(color, c); + } + return c; + }; + + const pos: number[] = [], nor: number[] = [], col: number[] = [], idx: number[] = [], ao: number[] = []; + const solid = (x: number, y: number, z: number): number => (grid.get(x, y, z) !== 0 ? 1 : 0); + + for (let z = 0; z < grid.sz; z++) + for (let y = 0; y < grid.sy; y++) + for (let x = 0; x < grid.sx; x++) { + const color = grid.get(x, y, z); + if (color === 0) continue; + emitVoxelFaces(x, y, z, color, rgb, levels, solid, pos, nor, col, idx, ao); + } + + return { positions: pos, normals: nor, colors: col, indices: idx, ao, vertexCount: ao.length, triangleCount: idx.length / 3 }; +} + +type RgbFn = (color: number) => [number, number, number]; +type SolidFn = (x: number, y: number, z: number) => number; + +/** Выпускает все видимые грани одного вокселя (хелпер ≤70 строк). */ +function emitVoxelFaces( + x: number, y: number, z: number, color: number, + rgb: RgbFn, levels: [number, number, number, number], solid: SolidFn, + pos: number[], nor: number[], col: number[], idx: number[], aoOut: number[], +): void { + for (const { axis, sign } of FACES) { + const nx = axis === 0 ? x + sign : x; + const ny = axis === 1 ? y + sign : y; + const nz = axis === 2 ? z + sign : z; + if (solid(nx, ny, nz)) continue; // culled: сосед занят + + const u = (axis + 1) % 3, v = (axis + 2) % 3; + const base = [x, y, z]; + const order = sign > 0 ? ORDER_POS : ORDER_NEG; + const start = pos.length / 3; + const vAo: number[] = []; + + for (const [du, dv] of order) { + const local = [0, 0, 0]; + local[axis] = sign > 0 ? 1 : 0; + local[u] = du; + local[v] = dv; + pos.push(base[0] + local[0], base[1] + local[1], base[2] + local[2]); + nor.push(axis === 0 ? sign : 0, axis === 1 ? sign : 0, axis === 2 ? sign : 0); + + // AO: два боковых соседа + диагональный, в плоскости соседней клетки + const offU = du === 1 ? 1 : -1; + const offV = dv === 1 ? 1 : -1; + const s1 = [nx, ny, nz], s2 = [nx, ny, nz], cr = [nx, ny, nz]; + s1[u] += offU; s2[v] += offV; cr[u] += offU; cr[v] += offV; + const aoV = cornerAo(solid(s1[0], s1[1], s1[2]), solid(s2[0], s2[1], s2[2]), solid(cr[0], cr[1], cr[2])); + vAo.push(aoV); + aoOut.push(aoV); + + const [r, g, b] = rgb(color); + const k = levels[aoV]; + col.push(r * k, g * k, b * k); + } + + // Флип диагонали против анизотропии AO: диагональ — через более тёмную пару + if (vAo[0] + vAo[2] > vAo[1] + vAo[3]) { + idx.push(start + 1, start + 2, start + 3, start + 1, start + 3, start); + } else { + idx.push(start, start + 1, start + 2, start, start + 2, start + 3); + } + } +} \ No newline at end of file diff --git a/v2/packages/engine/tools/agent-lib.mjs b/v2/packages/engine/tools/agent-lib.mjs new file mode 100644 index 0000000..8250d56 --- /dev/null +++ b/v2/packages/engine/tools/agent-lib.mjs @@ -0,0 +1,141 @@ +/** + * Агентная обвязка v2 — браузерный каркас проверок (часть ядра, решение v2). + * Жанронезависимая: порт/корень/приложение — параметры вызывающей стороны. + * Образец — agent-lib.mjs v1; урок v1 №2: визуальная подсистема готова + * только со скриншот-гейтом, поэтому обвязка живёт здесь с первого дня. + * + * Экспорт: launchBrowser, startDevServer, openGame, AgentClient, Checks, parseArgs. + * Запуск в headless Chromium (swiftshader) — WebGL работает без GPU. + */ +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import puppeteer from 'puppeteer-core'; + +export { sleep }; + +const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium'; + +const BROWSER_FLAGS = [ + '--no-sandbox', + '--enable-unsafe-swiftshader', + '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', +]; + +export async function launchBrowser() { + return puppeteer.launch({ + executablePath: CHROMIUM, + headless: true, + args: [...BROWSER_FLAGS, '--window-size=960,540'], + defaultViewport: { width: 960, height: 540 }, + }); +} + +/** + * Vite dev-сервер приложения (cwd — каталог приложения). Отдельная группа + * процессов, SIGTERM по завершении; если порт занят — сразу выходим + * (значит, сервер уже поднят снаружи). + */ +export function startDevServer({ port = 5299, cwd } = {}) { + const child = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { + cwd, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const waitReady = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`vite не поднялся за 30 с (${cwd})`)), 30000); + child.stdout.on('data', (d) => { + if (String(d).includes('Local:')) { clearTimeout(timer); resolve(); } + }); + child.on('exit', (code) => { clearTimeout(timer); reject(new Error(`vite упал (code ${code})`)); }); + }); + return { + child, + waitReady, + stop() { + try { process.kill(-child.pid, 'SIGTERM'); } catch { /* уже умер */ } + }, + }; +} + +/** Открывает страницу, ждёт маяк загрузки (window.__beacon = true) и собирает консоль. */ +export async function openGame(browser, { url, beacon = '__beacon', timeoutMs = 30000 } = {}) { + const page = await browser.newPage(); + const client = new AgentClient(page); + client.pageErrors = []; + page.on('pageerror', (e) => client.pageErrors.push(String(e))); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); + await page.waitForFunction(`window.${beacon} === true`, { timeout: timeoutMs }); + await sleep(1200); // первый кадр, прогрев шейдеров + return client; +} + +/** Тонкий клиент страницы: вызовы моста, скриншоты, консоль. */ +export class AgentClient { + constructor(page) { + this.page = page; + this.consoleLogs = []; + page.on('console', (msg) => this.consoleLogs.push(`[${msg.type()}] ${msg.text()}`)); + } + + /** Вызов метода window.__agent (если он есть). */ + async call(method, ...args) { + return this.page.evaluate((m, a) => window.__agent?.[m]?.(...a), method, args); + } + + /** Произвольное выражение в контексте страницы. */ + evaluate(fn, ...args) { + return this.page.evaluate(fn, ...args); + } + + /** Скриншот в файл (по умолчанию /tmp) — визуальный гейт. */ + async screenshot(path) { + await this.page.screenshot({ path }); + return path; + } + + /** Ошибки страницы, накопленные с открытия. */ + errors() { + return this.pageErrors; + } +} + +/** Мини-раннер проверок с JSON-отчётом (образец Checks из v1). */ +export class Checks { + constructor(name) { + this.name = name; + this.results = []; + this.started = Date.now(); + } + + run(title, fn) { + this.results.push({ title, fn }); + return this; + } + + async expect(title, actual, expected) { + const ok = Object.is(actual, expected) || + (typeof actual === 'object' && JSON.stringify(actual) === JSON.stringify(expected)); + this.results.push({ title, ok, actual, expected }); + if (!ok) this.failed = true; + return ok; + } + + finish() { + const ok = !this.failed; + return { name: this.name, ok, ms: Date.now() - this.started, results: this.results }; + } +} + +/** Простой парсер аргументов: --ключ значение / --флаг. */ +export function parseArgs(argv) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + const key = argv[i].slice(2); + if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) out[key] = argv[++i]; + else out[key] = true; + } else out._.push(argv[i]); + } + return out; +} \ No newline at end of file diff --git a/v2/packages/engine/tools/png.mjs b/v2/packages/engine/tools/png.mjs new file mode 100644 index 0000000..f9c2c97 --- /dev/null +++ b/v2/packages/engine/tools/png.mjs @@ -0,0 +1,119 @@ +/** + * 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 (RGBA) и 2 (RGB, расширяется в RGBA), 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 && colorType !== 2) || bitDepth !== 8) { + throw new Error(`PNG colorType=${colorType} bitDepth=${bitDepth} — жду RGB/RGBA 8-бит`); + } + const bpp = colorType === 6 ? 4 : 3; // байт на пиксель в сыром потоке + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * bpp; + const pixels = 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 = pixels.subarray(y * stride, (y + 1) * stride); + for (let x = 0; x < stride; x++) { + const a = x >= bpp ? cur[x - bpp] : 0; + const b = prev[x]; + const c = x >= bpp ? prev[x - bpp] : 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; + } + if (bpp === 4) return { width, height, rgba: pixels }; + // RGB -> RGBA: альфа 255 + const rgba = Buffer.alloc(height * width * 4); + for (let i = 0, j = 0; i < pixels.length; i += 3, j += 4) { + rgba[j] = pixels[i]; + rgba[j + 1] = pixels[i + 1]; + rgba[j + 2] = pixels[i + 2]; + rgba[j + 3] = 255; + } + return { width, height, rgba }; +} \ No newline at end of file