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