diff --git a/docs/plan.md b/docs/plan.md index 51b349d..5a365f6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -43,6 +43,7 @@ | 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 подтверждён в первый же день) | | 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()` — метод, а не геттер (тесты дважды ловили). | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index ed7ae0a..3ea24f2 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -57,15 +57,25 @@ `world.get`, шов чанков бесшовный (culled и AO через границы); - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); + - `src/math/rng.ts` — детерминированный Rng (mulberry32): next/int/range/chance/pick; + - `src/models/format.ts` — формат модели (JSON: size + плотный base64 + + слоты-палитра), encode/decode/validate (числовая валидация агентом); + - `src/models/generators.ts` — процедурные генераторы: `treeModel` (варианты + 0 круглая / 1 колонна / 2 ель), `boulderModel` (union сфер, свет на + верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; + - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом + (half-open прямоугольник + вертикаль мира), возвращает число вокселей; - `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` (мир 6×6 чанков 96×96 «пепельный - луг», ремешинг по dirty-чанкам), `tools/checks/render.mjs` — скриншот-гейт: + луг», деревья/валуны — из генераторов через `stampModel`, дом — ручной путь, + ремешинг по dirty-чанкам), `tools/checks/render.mjs` — скриншот-гейт: нет pageerror, меш собран, тени ползут (утро/вечер), апскейл кратен 2×2, многокрасочность, 36 чанков, постройка+мешинг < 2 с (замер ~0.5 с), - `setVoxel` на шве меняет кадр. - Мост демки: `window.__agent.setSun(k)`/`stats()`/`setVoxel(x,y,z,c)`/`chunkStats()`/`perf()`. + `setVoxel` на шве меняет кадр, 12+ деревьев всех 3 вариантов, генератор + детерминирован и варианты различаются (12 проверок). + Мост демки: `window.__agent.setSun(k)`/`stats()`/`setVoxel(x,y,z,c)`/`chunkStats()`/`modelStats()`/`stampTree(v,x,z,seed)`/`perf()`. ## Известные грабли (уже собранные) @@ -76,6 +86,8 @@ - **Скриншоты headless Chromium — RGB (colorType 2), без альфы** — декодер `tools/png.mjs` v2 это умеет (у v1 умел только RGBA). - **three без типов**: нужен `@types/three` в devDependencies движка. +- **`VoxelGrid.count()` — метод, а не геттер**: в тестах легко написать + `g.count` без скобок и получить функцию (дважды ловили). ## Рабочие привычки diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 9a6590c..cdf5569 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -1,10 +1,14 @@ /** - * Демо-полигон v2: воксельный мир 3×3 чанков (пепельный луг) под скриншот-гейтом. + * Демо-полигон v2: воксельный мир 6×6 чанков (пепельный луг) под скриншот-гейтом. * Мир живёт в воксельных единицах, проекция — только в VoxelRenderer. - * Ремешинг по dirty-чанкам: setVoxel моста правит ровно затронутые чанки. - * Палитра — слоты арт-библии v1; тёплый цвет — только жизнь. + * Деревья/валуны — из процедурных генераторов моделей (штамповка с клиппингом); + * дом — пример ручного пути. Ремешинг по dirty-чанкам: правки моста + * меняют ровно затронутые чанки. Палитра — слоты арт-библии v1. */ -import { VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX } from '@rpg/engine'; +import { + VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, + treeModel, boulderModel, stampModel, type Variant, +} from '@rpg/engine'; import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex @@ -26,39 +30,37 @@ const CHUNKS = 6; // мир 6×6 чанков (96×96) — демо масштаба движка const S = CHUNKS * CHUNK_SX; // 96 — сторона площадки -/** Сценка: земля, деревья, дом, валун, цветы — пишется прямо в мир. */ -function buildScene(world: VoxelWorld): void { +/** Сценка: земля, генераторные деревья/валуны, дом, цветы. */ +function buildScene(world: VoxelWorld): { trees: number; boulders: number; treeVariants: number[] } { for (let x = 0; x < S; x++) for (let z = 0; z < S; z++) { world.set(x, 0, z, 2); const patch = (x * 7 + z * 13) % 11 < 3; // детерминированные пятна world.set(x, 1, z, patch ? 3 : 1); } - // деревья по площадке; (16,20) — ровно на шве x=CHUNK_SX (проверка шва глазами) - for (const [tx, tz, h] of [ - [6, 8, 4], [24, 12, 5], [10, 25, 4], [CHUNK_SX, 20, 5], - [52, 30, 4], [62, 54, 5], [40, 70, 4], [30, 60, 5], - [70, 40, 4], [86, 22, 5], [80, 80, 4], [50, 88, 5], - ] as const) { - tree(world, tx, tz, h); + // деревья из генератора: [x, z, вариант, сид]; (16,20) — на шве x=CHUNK_SX + const treeSpots: ReadonlyArray = [ + [6, 8, 0, 11], [24, 12, 1, 12], [10, 25, 2, 13], [CHUNK_SX, 20, 0, 14], + [52, 30, 1, 15], [62, 54, 2, 16], [40, 70, 0, 17], [30, 60, 1, 18], + [70, 40, 2, 19], [86, 22, 0, 20], [80, 80, 1, 21], [50, 88, 2, 22], + ]; + const treeVariants = [0, 0, 0]; + for (const [tx, tz, v, seed] of treeSpots) { + stampModel(world, treeModel(v, seed), tx, 2, tz); + treeVariants[v]++; } house(world, 44, 44); - boulder(world, 52, 52); + // валуны из генератора: [x, z, вариант, сид] + const boulderSpots: ReadonlyArray = [ + [52, 52, 1, 31], [70, 72, 0, 32], [20, 76, 2, 33], + ]; + for (const [bx, bz, v, seed] of boulderSpots) { + stampModel(world, boulderModel(v, seed), bx, 2, bz); + } for (const [fx, fz] of [[38, 50], [46, 56], [56, 48], [36, 38]] as const) { world.set(fx, 2, fz, 9); // цветок — единственный тёплый цвет } -} - -/** Дерево: ствол + двухслойная крона со светлым верхом. */ -function tree(world: VoxelWorld, x: number, z: number, h: number): void { - for (let y = 2; y < 2 + h; y++) world.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++) { - world.set(x + dx, top + dy, z + dz, dy === 0 ? 3 : 5); // низ кроны темнее - } - world.set(x, top + 2, z, 5); + return { trees: treeSpots.length, boulders: boulderSpots.length, treeVariants }; } /** Дом: стены P4 со светлыми пилястрами, ступенчатая крыша R2. */ @@ -82,15 +84,11 @@ } } -/** Валун: неровная глыба, свет сверху. */ -function boulder(world: VoxelWorld, 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) world.set(x + dx, 2 + dy, z + dz, dy ? 7 : 6); -} +/** Валун — из генератора boulderModel (ручной вариант удалён: урок №4). */ const world = new VoxelWorld(24); const buildStart = performance.now(); -buildScene(world); +const sceneInfo = buildScene(world); const canvas = document.getElementById('scene') as HTMLCanvasElement; const renderer = new VoxelRenderer(canvas, { target: [S / 2, 2, S / 2], viewSize: 17 }); @@ -126,6 +124,16 @@ function chunkStats(): { chunks: number; meshes: number } { return { chunks: world.chunkCount, meshes: chunkMeshes.size }; } +/** Статистика генераторных моделей в сцене. */ +function modelStats(): { trees: number; boulders: number; treeVariants: number[] } { + return { ...sceneInfo }; +} +/** Пробная штамповка дерева генератором (для гейта: детерминизм, варианты). */ +function stampTree(v: number, x: number, z: number, seed: number): number { + const placed = stampModel(world, treeModel(v as Variant, seed), x, 2, z); + remeshDirty(); + return placed; +} const agent = { setSun(k: number): void { sunK = k; renderer.setSun(k); }, setVoxel(x: number, y: number, z: number, color: number): void { @@ -134,6 +142,8 @@ }, stats, chunkStats, + modelStats, + stampTree, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, diff --git a/v2/apps/demo/tools/checks/render.mjs b/v2/apps/demo/tools/checks/render.mjs index 2ef917e..f70a706 100644 --- a/v2/apps/demo/tools/checks/render.mjs +++ b/v2/apps/demo/tools/checks/render.mjs @@ -71,6 +71,17 @@ const cs2 = await client.call('chunkStats'); check('dirty-чанки после правки схлынули', cs2.chunks === 36 && cs2.meshes === 36, cs2); + // Генераторы моделей: деревья/валуны в сцене, все варианты представлены + const ms = await client.call('modelStats'); + check('деревья из генераторов: 12+, все 3 варианта', ms.trees >= 12 && ms.treeVariants.every((n) => n > 0), ms); + check('валуны из генераторов: 3 в сцене', ms.boulders >= 3, ms); + + // Детерминизм (дважды один сид → одно число) и различие вариантов + const t1 = await client.call('stampTree', 0, 1, 92, 501); + const t2 = await client.call('stampTree', 0, 1, 92, 501); + const t3 = await client.call('stampTree', 1, 1, 92, 501); + check('генератор детерминирован, варианты различаются', t1 === t2 && t1 !== t3, { t1, t3 }); + report.ok = report.checks.every((c) => c.ok); } finally { await browser.close(); diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 87ee760..e915179 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,4 +23,11 @@ export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer } from './render/voxelRenderer'; -export type { VoxelRendererOptions } from './render/voxelRenderer'; \ No newline at end of file +export type { VoxelRendererOptions } from './render/voxelRenderer'; +export { Rng } from './math/rng'; +export { encodeModel, decodeModel, validateModel, assertModelValid } from './models/format'; +export type { VoxelModel } from './models/format'; +export { treeModel, boulderModel } from './models/generators'; +export type { Variant } from './models/generators'; +export { stampModel } from './models/stamp'; +export type { StampClip } from './models/stamp'; \ No newline at end of file diff --git a/v2/packages/engine/src/math/__tests__/rng.test.ts b/v2/packages/engine/src/math/__tests__/rng.test.ts new file mode 100644 index 0000000..35ba6f9 --- /dev/null +++ b/v2/packages/engine/src/math/__tests__/rng.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { Rng } from '../rng'; + +describe('Rng (mulberry32)', () => { + it('детерминирован: один сид — одна последовательность', () => { + const a = new Rng(42); + const b = new Rng(42); + const seqA = Array.from({ length: 20 }, () => a.nextUint()); + const seqB = Array.from({ length: 20 }, () => b.nextUint()); + expect(seqA).toEqual(seqB); + }); + + it('разные сиды — разные последовательности', () => { + const a = new Rng(1); + const b = new Rng(2); + const seqA = Array.from({ length: 20 }, () => a.nextUint()); + const seqB = Array.from({ length: 20 }, () => b.nextUint()); + expect(seqA).not.toEqual(seqB); + }); + + it('next() в [0, 1) на большой выборке', () => { + const r = new Rng(7); + for (let i = 0; i < 10000; i++) { + const v = r.next(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('range() в границах включительно', () => { + const r = new Rng(9); + for (let i = 0; i < 2000; i++) { + const v = r.range(-2, 3); + expect(v).toBeGreaterThanOrEqual(-2); + expect(v).toBeLessThanOrEqual(3); + expect(Number.isInteger(v)).toBe(true); + } + }); + + it('range(5,5) всегда 5; int(1) всегда 0', () => { + const r = new Rng(3); + for (let i = 0; i < 10; i++) { + expect(r.range(5, 5)).toBe(5); + expect(r.int(1)).toBe(0); + } + }); + + it('pick() выдаёт только элементы массива', () => { + const r = new Rng(11); + const items = ['a', 'b', 'c'] as const; + for (let i = 0; i < 100; i++) expect(items).toContain(r.pick(items)); + }); + + it('невалидный сид бросает', () => { + expect(() => new Rng(-1)).toThrow(); + expect(() => new Rng(1.5)).toThrow(); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/math/rng.ts b/v2/packages/engine/src/math/rng.ts new file mode 100644 index 0000000..9f0977b --- /dev/null +++ b/v2/packages/engine/src/math/rng.ts @@ -0,0 +1,48 @@ +/** + * Детерминированный ГПСЧ (mulberry32) — генераторы моделей и мир зависят + * только от сида, не от порядка вызовов за его пределами. Чистая математика. + */ +export class Rng { + private state: number; + + constructor(seed: number) { + if (!Number.isInteger(seed) || seed < 0) { + throw new Error(`Rng: сид должен быть неотрицательным целым, дано ${seed}`); + } + this.state = seed >>> 0; + } + + /** [0, 2^32) как беззнаковое целое. */ + nextUint(): number { + this.state = (this.state + 0x6d2b79f5) >>> 0; + let t = this.state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0); + } + + /** [0, 1). */ + next(): number { + return this.nextUint() / 4294967296; + } + + /** Целое в [0, n). */ + int(n: number): number { + return Math.floor(this.next() * n); + } + + /** Целое в [min, max] включительно. */ + range(min: number, max: number): number { + return min + this.int(max - min + 1); + } + + /** Логический выбор с вероятностью p. */ + chance(p: number): boolean { + return this.next() < p; + } + + /** Выбор случайного элемента массива. */ + pick(items: readonly T[]): T { + return items[this.int(items.length)]; + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/models/__tests__/format.test.ts b/v2/packages/engine/src/models/__tests__/format.test.ts new file mode 100644 index 0000000..a9142ce --- /dev/null +++ b/v2/packages/engine/src/models/__tests__/format.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { VoxelGrid } from '../../voxel/grid'; +import { assertModelValid, decodeModel, encodeModel, validateModel } from '../format'; + +/** Маленькая сетка-фабрика: 2×3×2 с одним цветным вокселем. */ +function makeGrid(): VoxelGrid { + const g = new VoxelGrid(2, 3, 2); + g.set(1, 2, 1, 3); + g.set(0, 0, 0, 1); + return g; +} + +const PALETTE = { 1: '#4a5340', 3: '#626c4c' }; + +describe('формат модели (encode/decode/validate)', () => { + it('roundtrip: encode→decode даёт ту же сетку', () => { + const g = makeGrid(); + const back = decodeModel(encodeModel(g, PALETTE)); + expect(back.sx).toBe(2); + expect(back.sy).toBe(3); + expect(back.sz).toBe(2); + for (let z = 0; z < 2; z++) + for (let y = 0; y < 3; y++) + for (let x = 0; x < 2; x++) expect(back.get(x, y, z)).toBe(g.get(x, y, z)); + }); + + it('encode не мутирует исходную сетку', () => { + const g = makeGrid(); + const before = g.count; + encodeModel(g, PALETTE); + expect(g.count).toBe(before); + }); + + it('validate: валидная модель — пустой список проблем', () => { + const m = encodeModel(makeGrid(), PALETTE); + expect(validateModel(m)).toEqual([]); + }); + + it('validate: не-объект и плохой размер', () => { + expect(validateModel(null).length).toBeGreaterThan(0); + expect(validateModel('x').length).toBeGreaterThan(0); + const m = encodeModel(makeGrid(), PALETTE); + expect(validateModel({ ...m, size: [0, 3, 2] }).some((p) => p.includes('size'))).toBe(true); + expect(validateModel({ ...m, size: [2, 3] }).some((p) => p.includes('size'))).toBe(true); + }); + + it('validate: битый base64 и неверная длина данных', () => { + const m = encodeModel(makeGrid(), PALETTE); + expect(validateModel({ ...m, data: 'не base64!!!' }).some((p) => p.includes('base64'))).toBe(true); + expect(validateModel({ ...m, size: [2, 3, 3] }).some((p) => p.includes('байт'))).toBe(true); + }); + + it('validate: слоты вне 1..255, плохой hex, воксель без слота', () => { + const m = encodeModel(makeGrid(), PALETTE); + expect(validateModel({ ...m, palette: { 0: '#ffffff' } }).some((p) => p.includes('1..255'))).toBe(true); + expect(validateModel({ ...m, palette: { 1: 'красный' } }).some((p) => p.includes('hex'))).toBe(true); + // воксель слота 9, которого нет в палитре + const bad = { ...m, data: btoa(String.fromCharCode(9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) }; + expect(validateModel(bad).some((p) => p.includes('отсутствует в палитре'))).toBe(true); + }); + + it('assertModelValid кидает с текстом первой проблемы', () => { + const m = encodeModel(makeGrid(), PALETTE); + expect(() => assertModelValid({ ...m, size: [1, 1, 9999] })).toThrow(/size/); + expect(() => assertModelValid(m)).not.toThrow(); + }); + + it('decodeModel бросает на невалидной модели', () => { + expect(() => decodeModel({ size: [2, 2, 2], palette: { 1: '#111111' }, data: 'AAAA' })).toThrow(); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/models/__tests__/generators.test.ts b/v2/packages/engine/src/models/__tests__/generators.test.ts new file mode 100644 index 0000000..2ff6e1c --- /dev/null +++ b/v2/packages/engine/src/models/__tests__/generators.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { treeModel, boulderModel } from '../generators'; +import { decodeModel } from '../format'; +import { VoxelWorld } from '../../world/world'; +import { stampModel } from '../stamp'; + +const NEI = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]] as const; + +describe('генераторы деревьев', () => { + it('детерминированы: один (вид, сид) → идентичная модель', () => { + for (const v of [0, 1, 2] as const) { + expect(JSON.stringify(treeModel(v, 7))).toBe(JSON.stringify(treeModel(v, 7))); + } + }); + + it('разные сиды дают разные данные', () => { + expect(treeModel(0, 1).data).not.toBe(treeModel(0, 2).data); + }); + + it('варианты различаются при одном сиде', () => { + const models = [treeModel(0, 5), treeModel(1, 5), treeModel(2, 5)]; + expect(new Set(models.map((m) => JSON.stringify(m))).size).toBe(3); + }); + + for (const v of [0, 1, 2] as const) { + it(`вид ${v}: непустая, ствол есть и связан с кроной`, () => { + const g = decodeModel(treeModel(v, 123)); + expect(g.count()).toBeGreaterThan(0); + let trunk = 0, linked = false; + for (let z = 0; z < g.sz; z++) + for (let y = 0; y < g.sy; y++) + for (let x = 0; x < g.sx; x++) { + if (g.get(x, y, z) !== 4) continue; + trunk++; + for (const [dx, dy, dz] of NEI) { + const n = g.get(x + dx, y + dy, z + dz); + if (n === 3 || n === 5) linked = true; + } + } + expect(trunk).toBeGreaterThan(0); + expect(linked).toBe(true); + }); + } +}); + +describe('генераторы валунов', () => { + it('детерминированы; разные сиды различаются', () => { + expect(JSON.stringify(boulderModel(1, 9))).toBe(JSON.stringify(boulderModel(1, 9))); + expect(boulderModel(1, 9).data).not.toBe(boulderModel(1, 10).data); + }); + + for (const v of [0, 1, 2] as const) { + it(`вид ${v}: непустой, стоит на земле, свет на верхушках столбцов`, () => { + const g = decodeModel(boulderModel(v, 77)); + expect(g.count()).toBeGreaterThan(0); + let ground = 0; + for (let z = 0; z < g.sz; z++) + for (let x = 0; x < g.sx; x++) { + let top = -1; + for (let y = 0; y < g.sy; y++) { + const c = g.get(x, y, z); + if (c !== 0) { if (y === 0) ground++; top = y; } + } + if (top >= 0) expect(g.get(x, top, z)).toBe(7); // свет сверху + } + expect(ground).toBeGreaterThan(0); + }); + } + + it('вариант = размер: s растёт с видом', () => { + expect(boulderModel(0, 3).size[0]).toBe(5); + expect(boulderModel(2, 3).size[0]).toBe(7); + }); +}); + +describe('штамповка в мир', () => { + /** Пустой мир с «землёй» y=0..1 в 2×2 чанках. */ + function groundWorld(): VoxelWorld { + const w = new VoxelWorld(24); + for (let x = 0; x < 32; x++) for (let z = 0; z < 32; z++) w.set(x, 0, z, 2); + w.takeDirty(); + return w; + } + + it('ставит все воксели модели и возвращает их число', () => { + const w = groundWorld(); + const m = boulderModel(0, 5); + const expected = decodeModel(m).count(); + expect(stampModel(w, m, 4, 2, 4)).toBe(expected); + // в области штампа столько непустых вокселей, сколько в модели + let nonzero = 0; + const [sx, sy, sz] = m.size; + for (let ly = 0; ly < sy; ly++) + for (let lz = 0; lz < sz; lz++) + for (let lx = 0; lx < sx; lx++) if (w.get(4 + lx, 2 + ly, 4 + lz) !== 0) nonzero++; + expect(nonzero).toBe(expected); + }); + + it('клиппинг обрезает выходящую за границу часть', () => { + const w = groundWorld(); + const m = treeModel(0, 5); + const full = decodeModel(m); + // maxX=6 (half-open): столбцы x≥6 мира не ставятся + const placed = stampModel(w, m, 2, 2, 2, { maxX: 6 }); + let clippedOut = 0; + for (let ly = 0; ly < full.sy; ly++) + for (let lz = 0; lz < full.sz; lz++) + for (let lx = 0; lx < full.sx; lx++) + if (full.get(lx, ly, lz) !== 0 && 2 + lx >= 6) clippedOut++; + expect(placed).toBe(full.count() - clippedOut); + expect(placed).toBeLessThan(full.count()); + }); + + it('полный клиппинг → 0 поставленных, мир не растёт', () => { + const w = groundWorld(); + const chunks = w.chunkCount; + const m = treeModel(1, 5); + expect(stampModel(w, m, 100, 2, 100, { minX: 0, maxX: 32, minZ: 0, maxZ: 32 })).toBe(0); + expect(w.chunkCount).toBe(chunks); + }); + + it('вне вертикали мира (y) не ставится', () => { + const w = new VoxelWorld(4); + const m = treeModel(1, 5); + expect(stampModel(w, m, 0, 3, 0)).toBeLessThan(decodeModel(m).count()); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/models/format.ts b/v2/packages/engine/src/models/format.ts new file mode 100644 index 0000000..fd6130f --- /dev/null +++ b/v2/packages/engine/src/models/format.ts @@ -0,0 +1,125 @@ +/** + * Формат воксельной модели (JSON): размер, плотные данные base64, + * слоты палитры. Модель — данные: генераторы/импорт .vox/ручная правка + * дают один и тот же формат; валидация — числовая, без рендера. + * + * Слой Y растёт вверх; порядок вокселей в данных: x, затем y, затем z + * (idx = x + sx*(y + sy*z) — как в VoxelGrid). + */ +import { VoxelGrid } from '../voxel/grid'; + +export interface VoxelModel { + /** Размер по осям [sx, sy, sz], каждая > 0. */ + size: [number, number, number]; + /** Слоты палитры: слот-индекс сцены → hex; значения 1..255 — воксели ссылаются на ключи. */ + palette: Record; + /** base64 плотных данных: 1 байт на воксель, 0 = пусто. */ + data: string; +} + +const MAX_DIM = 1024; // предел по оси — защита от мусора и OOM +const MAX_VOXELS = 32 * 1024 * 1024; // ~32 МБ данных + +/** Кодирует сетку в модель: палитра берётся из карты слот→hex. */ +export function encodeModel(grid: VoxelGrid, palette: Record): VoxelModel { + const bytes = new Uint8Array(grid.sx * grid.sy * grid.sz); + for (let i = 0; i < bytes.length; i++) bytes[i] = grid.data[i]; + return { + size: [grid.sx, grid.sy, grid.sz], + palette: { ...palette }, + data: bytesToBase64(bytes), + }; +} + +/** Декодирует модель в сетку. Бросает на невалидных данных (см. validateModel). */ +export function decodeModel(model: VoxelModel): VoxelGrid { + assertModelValid(model); + const [sx, sy, sz] = model.size; + const bytes = base64ToBytes(model.data); + if (bytes.length !== sx * sy * sz) { + throw new Error(`decodeModel: данных ${bytes.length} байт, а нужно ${sx * sy * sz}`); + } + const grid = new VoxelGrid(sx, sy, sz); + for (let i = 0; i < bytes.length; i++) grid.data[i] = bytes[i]; + return grid; +} + +/** + * Числовая валидация модели без декодирования. Возвращает список проблем; + * пустой список = модель валидна. Проверяет: размер, объём, hex-слоты, + * что данные — валидный base64 нужной длины, что байты ссылаются на слоты. + */ +export function validateModel(model: unknown): string[] { + const problems: string[] = []; + const m = model as Partial | null; + if (!m || typeof m !== 'object') return ['модель — не объект']; + if (!Array.isArray(m.size) || m.size.length !== 3 || + m.size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_DIM)) { + problems.push(`size должен быть [sx,sy,sz] с целыми 1..${MAX_DIM}`); + return problems; // без размера дальше проверять нечего + } + const [sx, sy, sz] = m.size; + const volume = sx * sy * sz; + if (volume > MAX_VOXELS) problems.push(`объём ${volume} > ${MAX_VOXELS}`); + + if (!m.palette || typeof m.palette !== 'object') { + problems.push('palette должен быть объектом слот→hex'); + } else { + for (const [slot, hex] of Object.entries(m.palette)) { + const idx = Number(slot); + if (!Number.isInteger(idx) || idx < 1 || idx > 255) { + problems.push(`слот «${slot}» вне 1..255`); + } + if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(hex)) { + problems.push(`слот ${slot}: hex «${hex}» не вида #rgb/#rrggbb`); + } + } + } + + const bytes = safeBase64(m.data); + if (!bytes) { + problems.push('data — не валидный base64'); + return problems; + } + if (bytes.length !== volume) { + problems.push(`данных ${bytes.length} байт, а size требует ${volume}`); + return problems; + } + // Ссылки на слоты проверяем только при целостной палитре-объекте + const palette = m.palette; + if (palette && typeof palette === 'object' && !Array.isArray(palette)) { + for (const b of bytes) { + if (b !== 0 && !(b in palette)) { + problems.push(`воксель со слотом ${b} отсутствует в палитре`); + break; + } + } + } + return problems; +} + +/** Кидает первую проблему валидации (удобно в decode). */ +export function assertModelValid(model: unknown): void { + const problems = validateModel(model); + if (problems.length > 0) throw new Error(`модель невалидна: ${problems[0]}`); +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(bin); +} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +function safeBase64(s: unknown): Uint8Array | null { + if (typeof s !== 'string') return null; + try { return base64ToBytes(s); } catch { return null; } +} \ No newline at end of file diff --git a/v2/packages/engine/src/models/generators.ts b/v2/packages/engine/src/models/generators.ts new file mode 100644 index 0000000..bd1a4c0 --- /dev/null +++ b/v2/packages/engine/src/models/generators.ts @@ -0,0 +1,96 @@ +/** + * Процедурные генераторы воксельных моделей — основной путь генерации контента + * (docs/plan.md, «Направление v2»). Чистые функции: (вид, сид) → VoxelModel, + * детерминированно; никакой зависимости от мира и рендера. + * + * Слоты палитры — из арт-библии v1 (демка передаёт свою карту слот→hex). + */ +import { Rng } from '../math/rng'; +import { VoxelGrid } from '../voxel/grid'; +import { encodeModel } from './format'; +import type { VoxelModel } from './format'; + +// Слоты арт-библии, используемые генераторами +const TRUNK = 4; // R1 — ствол +const CROWN_DARK = 3; // G2 — крона, тень +const CROWN_LIGHT = 5; // G2H — крона, свет +const ROCK = 6; // P4 — камень +const ROCK_LIGHT = 7; // P6 — камень, свет + +export type Variant = 0 | 1 | 2; + +/** + * Дерево: вариант 0 — круглая крона, 1 — колонна, 2 — ель. + * Ствол входит в крону (соседство 6-связности), крона пятнистая — + * свет только сверху/снаружи, как в арт-библии. + */ +export function treeModel(variant: Variant, seed: number): VoxelModel { + const rng = new Rng(seed ^ 0x7ee1); + const trunkH = rng.range(3, 5); + let sx = 7, sz = 7, cx = 3, ch: number; + if (variant === 1) { sx = 5; sz = 5; cx = 2; ch = rng.range(3, 4); } + else if (variant === 2) { sx = 5; sz = 5; cx = 2; ch = rng.range(4, 5); } + else ch = rng.range(2, 3); + const g = new VoxelGrid(sx, trunkH + ch + 1, sz); + const mid = Math.floor(sx / 2); + + for (let y = 0; y < trunkH; y++) g.set(mid, y, mid, TRUNK); + + // Крона: предикат формы на слой dy от основания кроны (y = trunkH-1+dy) + const crownBase = trunkH - 1; + const r = 2 + rng.next() * 0.6; // джиттер радиуса фиксируем заранее + for (let dy = 0; dy < ch; dy++) { + const y = crownBase + dy; + for (let dz = -mid; dz <= mid; dz++) + for (let dx = -mid; dx <= mid; dx++) { + if (!inCrown(variant, dx, dy, dz, ch, r)) continue; + const light = dy === ch - 1 || rng.chance(0.3); + g.set(cx + dx, y, cx + dz, light ? CROWN_LIGHT : CROWN_DARK); + } + } + return encodeModel(g, { + [TRUNK]: '#5c3b2e', [CROWN_DARK]: '#626c4c', [CROWN_LIGHT]: '#7d8662', + }); +} + +/** Предикат формы кроны по варианту (dx/dz от центра, dy от основания). */ +function inCrown(variant: Variant, dx: number, dy: number, dz: number, ch: number, r: number): boolean { + const d2 = dx * dx + dz * dz; + if (variant === 0) return d2 + dy * dy <= r * r; + if (variant === 1) return d2 <= 1 && dy < ch; // колонна: радиус 1 + // ель: радиус слоя сужается кверху + const layerR = Math.max(0, 2 - Math.floor(dy / 2)); + return d2 <= layerR * layerR; +} + +/** + * Валун: union 2–4 сфер-глыб (вариант = размер и число глыб), свет + * на верхней поверхности каждого столбца. Всегда непустой. + */ +export function boulderModel(variant: Variant, seed: number): VoxelModel { + const rng = new Rng(seed ^ 0x3b9a); + const s = 5 + variant; + const g = new VoxelGrid(s, s - 1, s); + const blobs = 2 + variant; + const spheres: Array<{ cx: number; cy: number; cz: number; r: number }> = []; + for (let i = 0; i < blobs; i++) { + spheres.push({ + cx: rng.range(1, s - 2), cy: rng.range(0, 1), + cz: rng.range(1, s - 2), r: rng.range(1, 2), + }); + } + for (let z = 0; z < g.sz; z++) + for (let y = 0; y < g.sy; y++) + for (let x = 0; x < g.sx; x++) { + const hit = spheres.some((b) => + (x - b.cx) ** 2 + (y - b.cy) ** 2 + (z - b.cz) ** 2 <= b.r * b.r); + if (hit) g.set(x, y, z, ROCK); + } + if (g.count() === 0) g.set(Math.floor(s / 2), 0, Math.floor(s / 2), ROCK); // страховка + // свет — верхняя поверхность каждого столбца + for (let z = 0; z < g.sz; z++) + for (let x = 0; x < g.sx; x++) + for (let y = g.sy - 1; y >= 0; y--) + if (g.get(x, y, z) !== 0) { g.set(x, y, z, ROCK_LIGHT); break; } + return encodeModel(g, { [ROCK]: '#5b5b66', [ROCK_LIGHT]: '#94949e' }); +} \ No newline at end of file diff --git a/v2/packages/engine/src/models/stamp.ts b/v2/packages/engine/src/models/stamp.ts new file mode 100644 index 0000000..c6d7d7e --- /dev/null +++ b/v2/packages/engine/src/models/stamp.ts @@ -0,0 +1,37 @@ +/** + * Штамповка модели в мир: (0,0,0) модели → заданная мировая точка. + * Клиппинг: воксели вне вертикали мира и вне опционального прямоугольника + * (half-open [min, max)) пропускаются. Возвращает число поставленных + * вокселей; dirty-трекинг мира обычный (remeshDirty демки подхватит). + */ +import { VoxelWorld } from '../world/world'; +import { decodeModel } from './format'; +import type { VoxelModel } from './format'; + +export interface StampClip { + /** half-open границы клиппинга в мировых координатах. */ + minX?: number; minY?: number; minZ?: number; + maxX?: number; maxY?: number; maxZ?: number; +} + +export function stampModel( + world: VoxelWorld, model: VoxelModel, x: number, y: number, z: number, + clip: StampClip = {}, +): number { + const grid = decodeModel(model); + const inClip = (wx: number, wy: number, wz: number): boolean => + (clip.minX === undefined || wx >= clip.minX) && (clip.maxX === undefined || wx < clip.maxX) && + (clip.minY === undefined || wy >= clip.minY) && (clip.maxY === undefined || wy < clip.maxY) && + (clip.minZ === undefined || wz >= clip.minZ) && (clip.maxZ === undefined || wz < clip.maxZ); + let placed = 0; + for (let ly = 0; ly < grid.sy; ly++) + for (let lz = 0; lz < grid.sz; lz++) + for (let lx = 0; lx < grid.sx; lx++) { + const slot = grid.get(lx, ly, lz); + if (slot === 0) continue; + const wx = x + lx, wy = y + ly, wz = z + lz; + if (!inClip(wx, wy, wz)) continue; + if (world.set(wx, wy, wz, slot)) placed++; + } + return placed; +} \ No newline at end of file