diff --git a/docs/plan.md b/docs/plan.md index 5a365f6..ab366cd 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -44,6 +44,7 @@ | 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 подтверждён в первый же день) | | 23 | v2 трек 2: чанковый мир — `Chunk` (16×H×16), `VoxelWorld` (словарь чанков, get/set через границы, dirty-трекинг), `meshRegion` (мешер области по функции доступа — шов чанков бесшовный, AO через границы), демка на мире 3×3 чанков, мост `setVoxel`, гейт: воксель поставлен → кадр изменился, AO на шве не рвётся | ✅ сделано (2026-09-09): движок — `world/chunk.ts` + `world/world.ts` (правка кромки помечает соседа) + `world/worldMesher.ts`; мешер обобщён до `meshRegion` (meshVoxels — обёртка); тест шва — меш 4 чанков ≡ меш одной сеткой (поз+AO попиксельно), 20 тестов v2. Демка — мир 6×6 чанков (96×96) с ремешингом dirty, мост `setVoxel`/`chunkStats`/`perf` (правка атомарна с ремешингом). Гейт 9/9: постройка 36 чанков + мешинг ~0.4–0.5 с (лимит 2 с), столбик на шве x=48 меняет кадр (~1000 пикселей). Урок счёта: интерфейсная грань двух вокселей — два квада (12−2=10, не 11); правка занятого вокселя лицом не меняет счётчики (тест-столбик дважды «исчезал»: вне кадра и внутри дома) | | 24 | v2 трек 3: генераторы воксельных моделей — формат внутреннего JSON (size + dense base64, валидация агентом; .vox — позже), процедурные генераторы (деревья 3 варианта, валуны 3 варианта, детерминизм по сиду), штамповка в мир с клиппингом, демка на генераторах, гейт: варианты различаются, штампы на месте | ✅ (2026-09-09). `math/rng.ts` (mulberry32), `models/format.ts` (encode/decode/validate), `models/generators.ts` (деревья: круглая/колонна/ель; валуны: union сфер + свет на верхних гранях), `models/stamp.ts` (клиппинг half-open). Демка: 12 деревьев + 3 валуна из генераторов, ручные tree/boulder удалены (урок №4). Гейт 12 проверок: + модельная статистика, + детерминизм через мост (`stampTree` дважды → тот же счётчик, вариант 1 ≠ вариант 0). Грабля: `VoxelGrid.count()` — метод, а не геттер (тесты дважды ловили). | +| 25 | v2 трек 4: turntable-листы — визуальный гейт генераторов: страница со всеми вариантами моделей (деревья/валуны) × 4 поворота, скриншот в /tmp, проверки: ячейки непустые, варианты попарно различаются; основа для апрува сгенерированного контента агентом | ✅ (2026-09-09). `apps/demo/turntable.html` + `src/turntable.ts`: 6 моделей × 4 ракурса (0/45/90/135° — диагонали дают другой силуэт) на подставках 8×8×2, ракурс — `mesh.rotation.y` вокруг центра (geometry.translate −sx/2). Гейт `tools/checks/turntable.mjs`: центры ячеек считаются той же проекцией, что камера (зеркало lookAt-базиса), референсный кадр без моделей через `setModels(false)`, выборка — эллипс 48×42 в ромбе подставки; 5/5 зелёных, minDiff 570/490 при пороге 150. `launchBrowser({viewport})` в agent-lib (страница 720px выше дефолтного вьюпорта). Уроки: дробные координаты VoxelGrid.set молчу no-op (Uint8Array[4.5]); скриншот = вьюпорт браузера, канвас без центрирования; малые боксы у края ромба дают мало пикселей — выборка эллипсом. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 3ea24f2..51ad97f 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -65,7 +65,8 @@ верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом (half-open прямоугольник + вертикаль мира), возвращает число вокселей; - - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser/ + - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с + опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; - `tools/png.mjs` — PNG-кодек (colorType 6 и 2 — скриншоты Chromium RGB). - `apps/demo` — демо-полигон: `src/main.ts` (мир 6×6 чанков 96×96 «пепельный @@ -76,6 +77,13 @@ `setVoxel` на шве меняет кадр, 12+ деревьев всех 3 вариантов, генератор детерминирован и варианты различаются (12 проверок). Мост демки: `window.__agent.setSun(k)`/`stats()`/`setVoxel(x,y,z,c)`/`chunkStats()`/`modelStats()`/`stampTree(v,x,z,seed)`/`perf()`. +- `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты + генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост + `sheetInfo()` (геометрия листа для расчёта ячеек) и `setModels(on)` (скрыть + модели для референсного кадра). Канвас в левом верхнем углу — пиксели + скриншота считаются от (0,0). Гейт `tools/checks/turntable.mjs`: проекция + ячеек той же матрицей, что камера, эллипс 48×42 в ромбе подставки, + 5 проверок (ячейки непустые, варианты попарно различаются). ## Известные грабли (уже собранные) @@ -88,6 +96,11 @@ - **three без типов**: нужен `@types/three` в devDependencies движка. - **`VoxelGrid.count()` — метод, а не геттер**: в тестах легко написать `g.count` без скобок и получить функцию (дважды ловили). +- **Дробные координаты в `VoxelGrid.set` молча игнорируются** (индекс + Uint8Array 4.5 — no-op): центры ячеек считать целыми (`floor(pitch/2)`). +- **Скриншот = вьюпорт браузера**, не страница: канвас выше дефолтного + вьюпорта 960×540 обрезается снизу; `launchBrowser({viewport})` и канвас + без центрирования — иначе все координаты гейта плывут. ## Рабочие привычки diff --git a/v2/apps/demo/src/turntable.ts b/v2/apps/demo/src/turntable.ts new file mode 100644 index 0000000..8eff55d --- /dev/null +++ b/v2/apps/demo/src/turntable.ts @@ -0,0 +1,111 @@ +/** + * Turntable-лист: все варианты генераторов × 4 поворота на подставках. + * Визуальный гейт сгенерированного контента (урок №2): лист смотрится + * глазами через скриншот гейта; мост отдаёт геометрию листа, чтобы + * проверка считала экранные ячейки по той же проекции, что и камера. + */ +import { + VoxelRenderer, VoxelGrid, meshVoxels, treeModel, boulderModel, decodeModel, +} from '@rpg/engine'; +import type { VoxelModel, VoxelPalette } from '@rpg/engine'; +import * as THREE from 'three'; + +// Палитра арт-библии — та же, что в демке (слоты → hex) +const PALETTE: VoxelPalette = { + colors: { + 1: '#4a5340', 2: '#343b2c', 3: '#626c4c', 4: '#5c3b2e', 5: '#7d8662', + 6: '#5b5b66', 7: '#94949e', 8: '#82503a', 9: '#d99a32', 10: '#44454e', + }, +}; + +/** Модели листа: колонки — 6 моделей (деревья/валуны, все варианты). */ +const MODELS: ReadonlyArray<{ name: string; make: () => VoxelModel }> = [ + { name: 'tree0', make: () => treeModel(0, 5) }, + { name: 'tree1', make: () => treeModel(1, 5) }, + { name: 'tree2', make: () => treeModel(2, 5) }, + { name: 'boulder0', make: () => boulderModel(0, 5) }, + { name: 'boulder1', make: () => boulderModel(1, 5) }, + { name: 'boulder2', make: () => boulderModel(2, 5) }, +]; + +const COLS = MODELS.length; // модели по оси X +const ROWS = 4; // повороты по оси Z +const PITCH = 9; // шаг ячеек (одинаковый по X и Z) +const YAWS = [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4]; // диагонали дают другой силуэт + +const sheetW = COLS * PITCH; +const sheetD = ROWS * PITCH; +const target: [number, number, number] = [sheetW / 2, 2, sheetD / 2]; + +const canvas = document.getElementById('scene') as HTMLCanvasElement; +const renderer = new VoxelRenderer(canvas, { width: 480, height: 360, target, viewSize: 32 }); + +/** Центр ячейки (целый: воксельные координаты должны быть целыми). */ +function cellCenter(col: number, row: number): [number, number] { + return [col * PITCH + Math.floor(PITCH / 2), row * PITCH + Math.floor(PITCH / 2)]; +} + +/** Подставки ячеек: одна сетка на весь лист, плита 8×8×2 в центре каждой. */ +function buildPads(): VoxelGrid { + const grid = new VoxelGrid(sheetW, 2, sheetD); + for (let col = 0; col < COLS; col++) + for (let row = 0; row < ROWS; row++) { + const [cx, cz] = cellCenter(col, row); + for (let dx = -4; dx < 4; dx++) + for (let dz = -4; dz < 4; dz++) { + grid.set(cx + dx, 0, cz + dz, 2); + const patch = ((cx + dx) * 7 + (cz + dz) * 13) % 11 < 3; + grid.set(cx + dx, 1, cz + dz, patch ? 3 : 1); + } + } + return grid; +} +renderer.addMesh(meshVoxels(buildPads(), PALETTE)); + +// Модели: вращение вокруг центра ячейки, диагональные ракурсы +const modelMeshes: THREE.Mesh[] = []; +for (let col = 0; col < COLS; col++) + for (let row = 0; row < ROWS; row++) { + const [cx, cz] = cellCenter(col, row); + const grid = decodeModel(MODELS[col].make()); + const mesh = renderer.addMesh(meshVoxels(grid, PALETTE)); + mesh.geometry.translate(-grid.sx / 2, 0, -grid.sz / 2); // ось вращения — центр модели + mesh.position.set(cx, 2, cz); + mesh.rotation.y = YAWS[row]; + modelMeshes.push(mesh); + } + +function stats(): { vertices: number; triangles: number } { + let vertices = 0, triangles = 0; + for (const m of modelMeshes) { + vertices += m.geometry.getAttribute('position').count; + triangles += (m.geometry.getIndex()?.count ?? 0) / 3; + } + return { vertices, triangles }; +} + +/** Геометрия листа для расчёта ячеек в гейте (та же проекция, что у камеры). */ +function sheetInfo() { + return { + cols: COLS, rows: ROWS, pitch: PITCH, + target, viewSize: 30, width: 480, height: 360, scale: 2, + models: MODELS.map((m) => m.name), + }; +} + +const agent = { + stats, + sheetInfo, + /** Скрыть/показать модели (подставки остаются) — референсный кадр гейта. */ + setModels(on: boolean): void { + for (const m of modelMeshes) m.visible = on; + }, +}; +(window as unknown as Record).__agent = agent; + +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/turntable.mjs b/v2/apps/demo/tools/checks/turntable.mjs new file mode 100644 index 0000000..b3c0f69 --- /dev/null +++ b/v2/apps/demo/tools/checks/turntable.mjs @@ -0,0 +1,131 @@ +/** + * Гейт turntable-листов: визуальная проверка сгенерированных моделей + * (урок №2). Лист = 6 моделей (деревья/валуны × варианты) × 4 поворота. + * Считает экранные центры ячеек той же проекцией, что VoxelRenderer, + * и попиксельно сравнивает кадр с моделями и кадр без них. + * Скриншоты: /tmp/v2_turntable.png, /tmp/v2_turntable_pads.png. + * + * Запуск: node apps/demo/tools/checks/turntable.mjs [--port 5299] + */ +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 server = startDevServer({ port, cwd: new URL('../../', import.meta.url).pathname }); + +const report = { name: 'turntable', 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; + // страница 960×720 — вьюпорт с запасом, иначе низ листа обрезается кадром + const browser = await launchBrowser({ viewport: { width: 960, height: 760 } }); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/turntable.html` }); + + const errs = client.errors(); + check('нет pageerror при загрузке листа', errs.length === 0, errs.slice(0, 3)); + + const info = await client.call('sheetInfo'); + const stats = await client.call('stats'); + check('меш листа собран (24 модели)', stats.triangles > 3000, stats); + check('лист: 6 моделей × 4 поворота', info.cols === 6 && info.rows === 4, info.models); + + const full = decodePng(readFileSync(await client.screenshot('/tmp/v2_turntable.png'))); + await client.call('setModels', false); + await sleep(300); + const pads = decodePng(readFileSync(await client.screenshot('/tmp/v2_turntable_pads.png'))); + await client.call('setModels', true); + + // Ячейки: проекция мирового центра ячейки той же матрицей, что камера + const cells = []; + for (let col = 0; col < info.cols; col++) + for (let row = 0; row < info.rows; row++) { + // центр ячейки — как на странице (целый: floor(pitch/2)) + const wx = col * info.pitch + Math.floor(info.pitch / 2); + const wz = row * info.pitch + Math.floor(info.pitch / 2); + const [px, py] = worldToPx([wx, 3, wz], info); + cells.push({ col, row, px, py, diff: cellDiff(full, pads, px, py) }); + } + const empty = cells.filter((c) => c.diff <= 150); + check('во всех 24 ячейках модель видна', empty.length === 0, + empty.length ? empty : { minDiff: Math.min(...cells.map((c) => c.diff)) }); + + // Варианты попарно различаются внутри группы (деревья 0-2, валуны 0-2) + const pairwise = []; + for (const [a, b] of [[0, 1], [1, 2], [0, 2], [3, 4], [4, 5], [3, 5]]) + for (let row = 0; row < info.rows; row++) { + const ca = cells[a * info.rows + row], cb = cells[b * info.rows + row]; + pairwise.push(cellDiff(full, full, ca.px, ca.py, cb.px, cb.py)); + } + const same = pairwise.filter((d) => d <= 150); + check('варианты попарно различаются (6 пар × 4 ракурса)', same.length === 0, + same.length ? { same } : { minDiff: Math.min(...pairwise) }); + + 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); + +/** + * Экранные координаты мировой точки — зеркалим VoxelRenderer: диметрия 2:1 + * (yaw π/4, тангенс возвышения 1/2), ортокамера, глаз на target + dir*4*viewSize. + * Возвращает центр ячейки в пикселях скриншота (internal × scale). + */ +function worldToPx(p, info) { + const norm = (v) => { const l = Math.hypot(...v); return v.map((c) => c / l); }; + const cross = (a, b) => [ + a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], + ]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const dir = norm([Math.cos(Math.PI / 4) * 0.5, 1, Math.sin(Math.PI / 4) * 0.5]); + const xAxis = norm(cross([0, 1, 0], dir)); + const yAxis = cross(dir, xAxis); + const eye = info.target.map((t, i) => t + dir[i] * info.viewSize * 4); + const rel = p.map((c, i) => c - eye[i]); + const aspect = info.width / info.height; + const ndcX = dot(rel, xAxis) / (info.viewSize * aspect); + const ndcY = dot(rel, yAxis) / info.viewSize; + return [ + ((ndcX + 1) / 2) * info.width * info.scale, + (1 - (ndcY + 1) / 2) * info.height * info.scale, + ]; +} + +/** + * Число различающихся пикселей в эллипсе вокруг центров ячеек. + * Один центр (cA=cB) → сравнение двух кадров; два центра → двух ячеек. + * Эллипс 48×42 вписан в ромб подставки (8×8 юнитов, ~5.6 px/юнит, + * полуоси ромба ~32×28 px) — не заезжает на соседние ячейки. + */ +function cellDiff(a, b, pxA, pyA, pxB = pxA, pyB = pyA) { + const RX = 24, RY = 21; + let diff = 0; + for (let dy = -RY; dy <= RY; dy++) + for (let dx = -RX; dx <= RX; dx++) { + if ((dx / RX) ** 2 + (dy / RY) ** 2 > 1) continue; + const ia = pixelIndex(a, pxA + dx, pyA + dy); + const ib = pixelIndex(b, pxB + dx, pyB + dy); + for (let k = 0; k < 3; k++) if (Math.abs(a.rgba[ia + k] - b.rgba[ib + k]) > 8) { diff++; break; } + } + return diff; +} + +function pixelIndex(png, x, y) { + const cx = Math.min(png.width - 1, Math.max(0, Math.round(x))); + const cy = Math.min(png.height - 1, Math.max(0, Math.round(y))); + return (cy * png.width + cx) * 4; +} \ No newline at end of file diff --git a/v2/apps/demo/turntable.html b/v2/apps/demo/turntable.html new file mode 100644 index 0000000..8cee47d --- /dev/null +++ b/v2/apps/demo/turntable.html @@ -0,0 +1,22 @@ + + + + + Turntable-лист v2 — варианты генераторов + + + + + + + \ No newline at end of file diff --git a/v2/packages/engine/tools/agent-lib.mjs b/v2/packages/engine/tools/agent-lib.mjs index 8250d56..ab57c69 100644 --- a/v2/packages/engine/tools/agent-lib.mjs +++ b/v2/packages/engine/tools/agent-lib.mjs @@ -22,12 +22,12 @@ '--autoplay-policy=no-user-gesture-required', ]; -export async function launchBrowser() { +export async function launchBrowser({ viewport = { width: 960, height: 540 } } = {}) { return puppeteer.launch({ executablePath: CHROMIUM, headless: true, - args: [...BROWSER_FLAGS, '--window-size=960,540'], - defaultViewport: { width: 960, height: 540 }, + args: [...BROWSER_FLAGS, `--window-size=${viewport.width},${viewport.height}`], + defaultViewport: viewport, }); }