diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index a1655ac..e07f908 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -69,11 +69,16 @@ - `src/voxel/grid.ts` — воксельная сетка (Uint8, 0 = пусто, иначе индекс палитры); - `src/voxel/mesher.ts` — culled-меш + классический AO 0..3 (схема 0fps, флип диагонали); `meshVoxels` для сетки, `meshRegion` — область по - функции доступа; чистая математика, тестируется без рендера; - - `src/world/chunk.ts` — чанк 16×H×16; `src/world/world.ts` — мир-словарь + функции доступа; текстурный слой: `opts.texAt` даёт per-vertex + `tiles`/`uvs` (4 вершины грани — один id тайля, uv 0..1); чистая + математика, тестируется без рендера; + - `src/world/chunk.ts` — чанк 16×H×16 (данные + параллельный tex-слой + тайлей); `src/world/world.ts` — мир-словарь чанков (get/set через границы, dirty-трекинг: правка боковой кромки - помечает соседа); `src/world/worldMesher.ts` — `meshChunk` через - `world.get`, шов чанков бесшовный (culled и AO через границы); + помечает соседа; getTex/setTex — слой тайлей); `src/world/worldMesher.ts` + — `meshChunk` через + `world.get`, шов чанков бесшовный (culled и AO через границы; слой + тайлей — из `world.getTex`); - `src/render/modelViewer.ts` — `ModelViewer` (трек 28): просмотрщик моделей для дев-панели — орбитальная камера поверх своего VoxelRenderer (текстурный слой работает), клип риги (samplePose в render, play/setTime), @@ -103,6 +108,8 @@ верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом (half-open прямоугольник + вертикаль мира), возвращает число вокселей; + `opts.texRemap` штампует текстурный слой модели в мир (локальный id + тайля → мировой); - `src/models/vox.ts` — импорт/экспорт .vox (MagicaVoxel): чанки MAIN/SIZE/XYZI/RGBA (PACK>1 — ошибка, неизвестные чанки пропускаются с детьми), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей; @@ -117,9 +124,12 @@ текстурой (арт 8×8 текселей: бахрома волос, глаза 2 текселя, рот — тайли 2×2 на 16 вокселях фасада z3 головы), волосы — макушка и затылок геометрией; над фасадом лица в той же z-колонке вокселей нет - (ракурс ~55°, урок трека 25); `MannequinPalette {skin?, hair?, torso?, - legs?, arms?, boots?, belt?, eyes?}` — дизайн параметром (тексели лица - ссылаются на те же слоты — перекраска красит и лицо); + (ракурс ~55°, урок трека 25); скины деталей (трек 29): тайли 2×2 на + подоле (тёмная кромка), застёжке на груди, подошвах сапогов + пряжка + (слот METAL 11) на поясе — 19 тайлей всего; `MannequinPalette {skin?, + hair?, torso?, legs?, arms?, boots?, belt?, eyes?, metal?}` — дизайн + параметром (тексели лица/скинов ссылаются на те же слоты — перекраска + красит и текстуры); - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine, порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера; - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest` @@ -982,6 +992,75 @@ - Эффекты в вьювере (частицы/свет у объекта) — не делались, открытый вопрос отложен (см. plan.md). +## Трек 29 (2026-09-11): редизайн мира + текстурный слой на world-меше + +Фидбек: «редизайн домов/башни/камня/кустов/колодца/Машины, вариативность +внешнего вида (дома, валуны), малый куст; скины-детализация персонажам и +домам». Подзадачи: олень/строения/природа (игра, `apps/game/src/areas.ts`), +движок — текстурный слой мира (#171), скины (#172). + +- **Текстурный слой мира** (движок, зеркально слою моделей трека 26): + - `Chunk.tex` — `Uint8Array` параллельно data (id тайля на воксель, + 0 = плоский цвет); `Chunk.getTex/setTex` с проверкой inside и валидацией + 0..255; `VoxelWorld.getTex/setTex` (несуществующий чанк — 0; dirty + как у set); + - мешер: `MeshOptions.texAt?: (x,y,z)=>number` (по умолчанию в + `meshChunk` — `world.getTex`), `VoxelMesh.tiles/uvs` — per-vertex: + 4 вершины грани несут один id тайля (`tiles`), `uv` — (du,dv) 0..1 + из ORDER_POS/ORDER_NEG (первая грань порядка FACES — −x); + - штамповка: `StampOptions.texRemap?: (localId)=>number` — id тайля + модели → мировой id слоя тайлей мира (без remap tex не штампуется); + - рендер: `worldTileAtlas(tiles: CloudTiles): WorldTileAtlas | null` — + экспорт движка; атлас-канвас строится той же `buildTileAtlas` (ячейка = 6 + суб-граней, шейдерная инъция `texturizeAtlasMaterial` общая с + облаком: атрибут `aTileId` + `uv` у BufferGeometry), пустой реестр — + null (материалы чанков остаются плоско-цветными). `WorldTileAtlas` + держит texture вне материалов чанков: applyTo подключает материал, + dispose — только у владельца атласа (материал чанка диспозится сам, + texture общий). +- **Строения** (`areas.ts`, UNIT=10): дом — `houseModel(kind, seed)`: + W24 H30 (tall V3/V4 = 40) D18 ROOF5, 3 стиля стен по сиду (кладка/доски/ + фахверк через `wallSlot`), цоколь 18, дверь-полотно 19 на z=D−1 (гейт + interact не тронут), окна со стеклом 10, крыша с коньком 20, труба 2×2; + варианты HOUSE=7 / HOUSE_V1..V4=24..27, сид дома — от тайла (каждый + дом свой). Башня 10×47×10 — круглая (кольцо от центра), пьеры 2×2 по + углам, бойницы, зубцы, рама колокола с колоколом и языком. Колодец — + сруб с торцами 20, тёмная вода 22, стойки/ворот/рукоять, шатровая + крыша. Машина 24×34×18 — клёпаный корпус с заклёпками 21, труба с + коленом, жерло со «искрой» 9, топка с углями, манометр. +- **Природа**: валун-купол `mapBoulderModel(variant, seed)` — H 6..8, + трещина 18, мох 23, рваный край; вариант по сиду тайла (BOULDER=28, + BOULDER_2/3 = 30/31). Кусты: обычный/высокий/малый (малый — четверть + площади, BUSH_2=32; высокий BUSH_3=33) — вариант по сиду (окно из 8: + 7→малый, 6→высокий). Декаталог дев-вьювера дополнен (bush2, boulder0..2, + house/house1/house2). +- **Палитра игры**: общие слоты 18..23 (18 P5 тёмный камень, 19 R3 + дверное полотно, 20 R4 доска, 21 M3 светлый металл, 22 W0 тёмная вода + шахты, 23 G3 мох) — stampModel НЕ ремапит палитру, слоты модели пишутся + в мир напрямую и рендерятся через PALETTE игры (main.ts). +- Тесты: 309 (+6: текстурный слой мешера, слой тайлей мира, штамповка + texRemap). Гейты areas/interact поправлены под UNIT=10 (падали и на + HEAD — константы остались от UNIT 3/5). +- **Скины (трек 29, #172)**: `apps/game/src/worldTex.ts` — реестр + глобальных тайлей мира (id 1..255) с дедупликацией по контенту + (одинаковый пиксель-арт у моделей с разными сидами занимает один id — + без дедупа реестр переполнился бы: у каждого дома свой сид); + `texRemapFor(model)` — переводчик для `stampModel({ texRemap })`, + areas.ts штампует с ним весь контент. Материалы чанков main.ts + сэмплируют ОДИН общий атлас реестра (`worldTileAtlas` движка): texture + живёт вне материалов и дисползится только при пересборке реестра, + у чанков дисползится только материал. Дом получил текстурный слой + (`houseTexture`): тайли 4×4 на гранях — кладка (тёмные швы 18 со + сдвигом), доски (горизонтальные венцы), штукатурка фахверка, дранка/ + ржавый металл крыши, дверное полотно, замковые камни углов; тайль + стены кладётся ТОЛЬКО на базовый слот стиля (6/20/7) — тёмные балки и + венцы (слот 4) остаются плоскими, иначе пропадают в арте. Персонажи + (манекен): скины деталей — подол/застёжка/подошвы + пряжка (слот 11). + Грабля: **записи каталога дев-вьювера обязаны передавать + `tiles: resolveTiles(model)`** — без них вьювер рисует модель плоскими + слотами (дома выглядели «мыльно»), а сиды записей подобраны так, чтобы + три дома показывали три разных стиля стен. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/areas.ts b/v2/apps/game/src/areas.ts index 363dc4b..fa0aa04 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -11,8 +11,9 @@ VoxelWorld, VoxelGrid, stampModel, encodeModel, Rng, type Variant, } from '@rpg/engine'; -import type { VoxelModel } from '@rpg/engine'; +import type { VoxelModel, VoxelTile, ModelTexture } from '@rpg/engine'; import mapsData from './data/maps.json'; +import { texRemapFor } from './worldTex'; /** Раскладка области (конвертация v1): тайлы RLE, blocked/tall/decor/props v1. */ export interface AreaLayout { @@ -101,112 +102,334 @@ return encodeModel(g, { 3: '#626c4c', 5: '#7d8662', 4: '#5c3b2e' }); } -/** Куст карты: низкая крона без ствола, ровно в тайл. */ +/** Куст карты (редизайн): приземистый (0), высокий с уступами (1) или + * малый — четверть площади обычного (2); рваный край по сиду. */ export function mapBushModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x5a51); - const H = variant === 1 ? 7 : 5; + // профиль ряда (порог d²): низ шире, верх уже + const PROFILE = variant === 1 ? [20, 14, 12, 9, 7, 5, 3, 2] + : variant === 2 ? [5, 4, 2] : [18, 10, 8, 4, 2]; + const H = PROFILE.length; + const R = variant === 2 ? 2 : 4; // малый — четверть площади обычного const g = new VoxelGrid(10, H, 10); for (let y = 0; y < H; y++) - for (let dz = -4; dz <= 4; dz++) - for (let dx = -4; dx <= 4; dx++) { + for (let dz = -R; dz <= R; dz++) + for (let dx = -R; dx <= R; dx++) { const d2 = dx * dx + dz * dz; - if (y === 0 ? d2 > 20 : d2 > 8) continue; // низ шире, верх уже - if (d2 > 0 && !rng.chance(0.8)) continue; - g.set(5 + dx, y, 5 + dz, y === H - 1 || rng.chance(0.25) ? 5 : 3); + if (d2 > PROFILE[y]) continue; + if (d2 > 0 && !rng.chance(0.85)) continue; // рваный край + g.set(5 + dx, y, 5 + dz, y === H - 1 || rng.chance(0.2) ? 5 : 3); } return encodeModel(g, { 3: '#626c4c', 5: '#7d8662' }); } -/** Валун карты: глыба-купол в тайл (не ниже 5 — не плитка), свет на верхушке. */ +/** Валун карты (редизайн): купол со скруглённым силуэтом (радиус тает + * к верхушке), рваный край, трещина по боку, мох на верхушке (жизнь!); + * вариант — крупность, сид — форма. */ export function mapBoulderModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x4b0d); - const H = 5 + Math.min(variant, 1) * 2 + (rng.next() < 0.4 ? 2 : 0); // 5..9 + const H = 6 + (variant % 3); // 6..8 — глыба, не плитка const g = new VoxelGrid(10, H, 10); for (let y = 0; y < H; y++) { - const w = y === 0 ? 10 : y === H - 1 ? 3 : 7; // купол: шире к низу - const o = (10 - w) >> 1; - for (let dz = 0; dz < w; dz++) - for (let dx = 0; dx < w; dx++) { - if (w === 7 && dx === 3 && dz === 3 && rng.chance(0.5)) continue; - g.set(o + dx, y, o + dz, y === H - 1 ? 7 : 6); + const t = y / (H - 1); // 0..1 по высоте + const r = 4.7 * Math.sqrt(1 - t * t * 0.9) + 0.3; // купол + for (let dz = -5; dz <= 4; dz++) + for (let dx = -5; dx <= 4; dx++) { + const d = Math.sqrt(dx * dx + dz * dz); + if (d > r + (rng.next() - 0.5)) continue; // рваный край + const crack = y > 0 && y < H - 1 && dx === ((y * 3 + seed) % 5) - 2 && Math.abs(dz) < 2; + const moss = y >= H - 3 && rng.next() < 0.3; + g.set(5 + dx, y, 5 + dz, moss ? 23 : crack ? 18 : y >= H - 3 ? 7 : 6); } } - return encodeModel(g, { 6: '#5b5b66', 7: '#94949e' }); + return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 18: '#47474f', 23: '#5e6b4a' }); } -/** Дом: каменные стены с окнами и дверью + двускатная ржавая крыша - * (конёк вдоль X, свес над стенами) + труба (~4 тайла — вдвое выше - * героя: строение, в которое заходят, а не будка). */ -export function houseModel(kind: number, _seed: number): VoxelModel { +// ---- Строения (редизайн трека 29): стены в трёх стилях по сиду (кладка, +// доски, фахверк), цоколь, дверь с косяками, окна с рамами, двускатная +// крыша с коньком, труба. Слоты — палитра сцены (6/7 камень, 4/8 дерево +// и ржавчина, 10 стекло, 18 цоколь, 19 дверь, 20 доска). ---- + +/** Тайль скина из 4 строк слотов по 4 текселя (строка 0 — верх грани), + * одинаковый на все 6 граней. Тексель = слот палитры модели. */ +function tile4(rows: number[]): VoxelTile { + const face = btoa(String.fromCharCode(...rows)); + return { size: 4, faces: [face, face, face, face, face, face] }; +} + +// Тайли скинов дома (трек 29): тексели ссылаются на слоты стен/крыши — +// перекраска слота красит и текстуру. +const T_BRICK = tile4([ // кладка: тёмные швы (18), вертикальные со сдвигом + 6, 6, 18, 6, + 6, 6, 18, 6, + 18, 18, 18, 18, + 18, 6, 6, 6, +]); +const T_PLANK = tile4([ // доски: горизонтальные венцы с тёмным швом (4) + 20, 20, 20, 20, + 20, 20, 20, 20, + 4, 4, 4, 4, + 20, 20, 20, 20, +]); +const T_PLASTER = tile4([ // штукатурка фахверка: редкие тёмные вкрапления (6) + 7, 7, 7, 7, + 7, 6, 7, 7, + 7, 7, 7, 7, + 7, 7, 6, 7, +]); +const T_SHINGLE = tile4([ // дранка/ржавый металл: тёмные ряды (4) + 8, 8, 8, 8, + 4, 4, 4, 4, + 8, 8, 8, 8, + 8, 8, 8, 8, +]); +const T_WOODSHINGLE = tile4([ // деревянная дранка (крыша слота 4): шов 8 + 4, 4, 4, 4, + 8, 8, 8, 8, + 4, 4, 4, 4, + 4, 4, 4, 4, +]); +const T_DOOR = tile4([ // дверное полотно: вертикальные доски со швом (4) + 19, 19, 4, 19, + 19, 19, 4, 19, + 19, 19, 4, 19, + 19, 19, 4, 19, +]); +const T_STONE = tile4([ // замковые камни углов: крупный блок с гранью (6) + 7, 7, 7, 7, + 7, 7, 7, 7, + 7, 7, 6, 7, + 7, 7, 7, 7, +]); + +/** Дом 24×18 (высокие подварианты 40): стиль стен и крыши, труба — по сиду; + * дверь на фасаде z=D−1, x10..13 сохранена (точка захода героя). Скин — + * текстурный слой: тайли кладки/досок/штукатурки/дранки на гранях стен. */ +export function houseModel(kind: number, seed: number): VoxelModel { const tall = kind === T.HOUSE_V3 || kind === T.HOUSE_V4; + const rng = new Rng(seed ^ 0x1d0f); const W = 24, H = tall ? 40 : 30, D = 18, ROOF = 5; + const wallH = H - ROOF; + const style = Math.floor(rng.next() * 3); // 0 кладка, 1 доски, 2 фахверк const g = new VoxelGrid(W, H, D); - for (let y = 0; y < H - ROOF; y++) + houseWalls(g, W, wallH, D, style); + houseDoor(g, D); + houseWindows(g, W, wallH, D, style, tall); + houseRoof(g, W, H, D, ROOF, rng.next() < 0.6); + if (rng.next() < 0.6) houseChimney(g, W, H, ROOF); + return encodeModel(g, { + 6: '#5b5b66', 7: '#94949e', 4: '#5c3b2e', 8: '#82503a', 10: '#44454e', + 18: '#47474f', 19: '#6e4a30', 20: '#74553e', + }, houseTexture(g, W, H, D, ROOF, style)); +} + +/** Текстурный слой дома: тайли по слоту и зоне вокселя (стены/крыша/дверь). + * Тайль стены — только на базовом слоте стиля (6/20/7); тёмные балки и + * венцы (слот 4) остаются плоскими, иначе пропадают в арте. */ +function houseTexture( + g: VoxelGrid, W: number, H: number, D: number, ROOF: number, style: number, +): ModelTexture { + const baseSlot = [6, 20, 7][style]; + const tiles: Record = { + 1: [T_BRICK, T_PLANK, T_PLASTER][style], 2: T_STONE, 3: T_SHINGLE, + 4: T_WOODSHINGLE, 5: T_DOOR, 6: T_BRICK, + }; + const tex = new Uint8Array(g.data.length); + for (let y = 0; y < H; y++) + for (let z = 0; z < D; z++) + for (let x = 0; x < W; x++) { + const slot = g.get(x, y, z); + if (slot === 0) continue; + let id = 0; + if (y >= H - ROOF) id = slot === 4 ? 4 : slot === 8 ? 3 : 0; // крыша + else if (slot === baseSlot) id = 1; // стена стиля + else if (slot === 4 && (x === 0 || x === W - 1) && (z === 0 || z === D - 1)) id = 2; // углы — замковые камни + else if (slot === 19) id = 5; // дверное полотно + else if (slot === 6) id = 6; // труба (камень) + if (id !== 0) tex[x + W * (y + H * z)] = id; + } + return { tiles, tex }; +} + +/** Слот стены по стилю: u — координата вдоль грани, y — высота. */ +function wallSlot(x: number, y: number, z: number, W: number, D: number, wallH: number, style: number): number { + const u = x === 0 || x === W - 1 ? z : x; // вдоль грани + const corner = (x === 0 || x === W - 1) && (z === 0 || z === D - 1); + if (corner) return style === 1 ? 4 : 7; // углы: тёмные доски / замковые камни + if (style === 0) return y % 3 === 2 && (u + (y / 3 | 0) * 2) % 4 === 0 ? 7 : 6; // швы кладки + if (style === 1) return y === wallH - 1 ? 4 : u % 3 === 0 ? 4 : 20; // доски + венец + return y === wallH - 1 || u % 6 === 2 ? 4 : 7; // фахверк: стойки в штукатурке +} + +/** Коробка стен (только рёбра) с цоколем в 2 вокселя. */ +function houseWalls(g: VoxelGrid, W: number, wallH: number, D: number, style: number): void { + for (let y = 0; y < wallH; y++) for (let z = 0; z < D; z++) for (let x = 0; x < W; x++) { const edge = x === 0 || z === 0 || x === W - 1 || z === D - 1; if (!edge) continue; - // проём на фасаде z=D-1 (видимом камере в изометрии) - const door = z === D - 1 && y <= 4 && x >= 10 && x <= 13; - const win = !door && y > 4 && y < H - ROOF - 1 && (x + z) % 5 === 0; - // дверь — тёплое дерево (окна — тёмное стекло, стена — камень) - g.set(x, y, z, door ? 9 : win ? 10 : 6); + g.set(x, y, z, y < 2 ? 18 : wallSlot(x, y, z, W, D, wallH, style)); } - // крыша: слои сужаются с двух сторон по z (конёк вдоль X), свес шире стен +} + +/** Дверь на фасаде (z = D−1): тёплое полотно, тёмные косяки, ручка. */ +function houseDoor(g: VoxelGrid, D: number): void { + for (let y = 0; y <= 4; y++) for (let x = 10; x <= 13; x++) g.set(x, y, D - 1, 19); + for (let y = 0; y <= 5; y++) { g.set(9, y, D - 1, 4); g.set(14, y, D - 1, 4); } + for (let x = 9; x <= 14; x++) g.set(x, 5, D - 1, 4); // притолока + g.set(13, 3, D - 1, 4); // ручка +} + +/** Окно 4×4 с рамой на плоскости стены (axis — фиксированная координата). */ +function windowOn(g: VoxelGrid, axis: 'x' | 'z', fix: number, u0: number, v0: number, frame: number): void { + for (let du = 0; du < 4; du++) + for (let dv = 0; dv < 4; dv++) { + const u = u0 + du, v = v0 + dv; + const border = du === 0 || dv === 0 || du === 3 || dv === 3; + const [x, y, z] = axis === 'x' ? [fix, v, u] : [u, v, fix]; + g.set(x, y, z, border ? frame : 10); + } +} + +/** Окна: пара на фасаде + пара на боковой стене; у высоких — второй ряд. */ +function houseWindows(g: VoxelGrid, W: number, wallH: number, D: number, style: number, tall: boolean): void { + const frame = style === 0 ? 7 : 4; + const y0 = Math.min(6, wallH - 6); + windowOn(g, 'z', D - 1, 2, y0, frame); + windowOn(g, 'z', D - 1, W - 6, y0, frame); + windowOn(g, 'x', 0, 4, y0, frame); + windowOn(g, 'x', W - 1, D - 8, y0, frame); + if (tall) { + windowOn(g, 'z', D - 1, 2, y0 + 8, frame); + windowOn(g, 'z', D - 1, W - 6, y0 + 8, frame); + } +} + +/** Двускатная крыша (конёк вдоль X, свес за стены): ржавый металл или + * тёмная дранка по сиду, ряд конька светлее. */ +function houseRoof(g: VoxelGrid, W: number, H: number, D: number, ROOF: number, rust: boolean): void { + const roof = rust ? 8 : 4; for (let dy = 0; dy < ROOF; dy++) { const y = H - ROOF + dy; - const z0 = dy * 2 - 1, z1 = D - dy * 2; - for (let z = Math.max(0, z0); z < Math.min(D, z1); z++) + for (let z = Math.max(0, dy * 2 - 1); z < Math.min(D, D - dy * 2); z++) for (let x = Math.max(0, dy - 1); x < Math.min(W, W - dy + 1); x++) - g.set(x, y, z, dy === ROOF - 1 ? 7 : 8); // конёк — светлее + g.set(x, y, z, dy === ROOF - 1 ? 20 : roof); } - // труба: 2×2 у края, торчит над скатом - for (let y = H - ROOF; y < H; y++) - for (let dz = 4; dz <= 5; dz++) - for (let dx = W - 6; dx <= W - 5; dx++) g.set(dx, y, dz, 6); - return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e', 7: '#94949e', 9: '#6e4a30' }); } -/** Колодец: каменное кольцо + ворот с брусом (tall 1 юнит). */ +/** Труба 2×2 над скатом: камень с тёмным жерлом на срезе. */ +function houseChimney(g: VoxelGrid, W: number, H: number, ROOF: number): void { + for (let y = H - ROOF - 1; y < H; y++) + for (let dz = 4; dz <= 5; dz++) + for (let dx = W - 6; dx <= W - 5; dx++) + g.set(dx, y, dz, y === H - 1 ? 10 : 6); +} + +/** Колодец (редизайн): сруб из брёвен с выпусками по углам, тёмная вода + * в шахте, деревянные стойки с металлическим воротом и ручкой, + * двускатная крыша на стойках (ржавый гонт, конёк — дерево). */ export function wellModel(_seed: number): VoxelModel { const g = new VoxelGrid(10, 12, 10); - for (let z = 0; z < 10; z++) - for (let x = 0; x < 10; x++) { - const edge = x === 0 || z === 0 || x === 9 || z === 9; - if (edge) g.set(x, 0, z, 6); - } - g.set(0, 2, 0, 4); - g.set(9, 2, 0, 4); - g.set(4, 6, 0, 10); // ворот (металл) - g.set(4, 10, 0, 4); - return encodeModel(g, { 6: '#5b5b66', 4: '#5c3b2e', 10: '#44454e' }); + // сруб: три ряда брёвен, торцы по углам светлее через ряд (выпуски) + for (let y = 0; y < 3; y++) + for (let z = 0; z < 10; z++) + for (let x = 0; x < 10; x++) { + const edge = x === 0 || z === 0 || x === 9 || z === 9; + if (edge) g.set(x, y, z, 4); + } + for (const [cx, cz] of [[0, 0], [9, 0], [0, 9], [9, 9]] as const) + for (let y = 0; y < 3; y += 2) g.set(cx, y, cz, 20); + // шахта: тёмная вода внутри кольца + for (let z = 3; z <= 6; z++) for (let x = 3; x <= 6; x++) g.set(x, 1, z, 22); + // стойки, ворот (металл) с ручкой + for (let y = 3; y <= 8; y++) for (const z of [4, 5]) { g.set(1, y, z, 4); g.set(8, y, z, 4); } + for (let x = 2; x <= 7; x++) g.set(x, 7, 4, 10); + g.set(2, 8, 4, 10); + g.set(2, 8, 3, 4); // ручка ворота + // крыша: двускатная вдоль X (скат по z), свес за сруб + const spans: Array<[number, number]> = [[0, 9], [2, 7], [4, 5]]; + for (let dy = 0; dy < 3; dy++) + for (let z = spans[dy][0]; z <= spans[dy][1]; z++) + for (let x = 0; x < 10; x++) g.set(x, 9 + dy, z, dy === 2 ? 4 : 8); + return encodeModel(g, { 4: '#5c3b2e', 20: '#74553e', 10: '#44454e', 8: '#82503a', 22: '#3a4550' }); } -/** Башня звона: колонна с площадкой и колоколом (высокая — доминирует в кадре). */ +/** Башня звона (редизайн): круглая кладка r≈4.5 с угловыми контрфорсами + * (углы тайла заняты — blocked-семантика сохраняется), цоколь, швы кладки, + * бойницы в два яруса, площадка с зубцами, колокол на деревянной раме. */ export function towerModel(_seed: number): VoxelModel { const g = new VoxelGrid(10, 47, 10); + // ствол: кольцо стены + 2×2 контрфорсы по углам, цоколь 2 вокселя for (let y = 0; y < 36; y++) for (let z = 0; z < 10; z++) - for (let x = 0; x < 10; x++) g.set(x, y, z, 6); - for (let x = 0; x < 10; x++) - for (let z = 0; z < 10; z++) g.set(x, 36, z, 7); // площадка, свет камня - g.set(4, 40, 4, 10); // колокол (металл) - g.set(4, 45, 4, 4); - return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 10: '#44454e', 4: '#5c3b2e' }); + for (let x = 0; x < 10; x++) { + const d2 = (x - 4.5) ** 2 + (z - 4.5) ** 2; + const pier = (x < 2 || x > 7) && (z < 2 || z > 7); + if (!(pier || (d2 <= 20.25 && d2 >= 10))) continue; + if (y < 2) { g.set(x, y, z, 18); continue; } + const seam = y % 3 === 2 && (x + z + (y / 3 | 0)) % 4 === 0; + g.set(x, y, z, pier || seam ? 7 : 6); + } + // бойницы: щели 1×3 в два яруса по сторонам света + for (const [sx, sz] of [[4, 0], [0, 4], [9, 4], [4, 9]] as const) + for (const y of [14, 15, 16, 24, 25, 26]) g.set(sx, y, sz, 10); + // площадка (свет камня) + зубцы через один по кольцу + for (let z = 0; z < 10; z++) + for (let x = 0; x < 10; x++) { + const d2 = (x - 4.5) ** 2 + (z - 4.5) ** 2; + const pier = (x < 2 || x > 7) && (z < 2 || z > 7); + if (d2 <= 20.25 || pier) g.set(x, 36, z, 7); + if (d2 <= 20.25 && d2 >= 10 && (x + z) % 2 === 0) { g.set(x, 37, z, 6); g.set(x, 38, z, 6); } + } + // рама колокола: стойки и поперечный брус, колокол — металл расширяется вниз + for (let y = 37; y <= 42; y++) for (const z of [4, 5]) { g.set(1, y, z, 4); g.set(8, y, z, 4); } + for (let x = 1; x <= 8; x++) for (const z of [4, 5]) g.set(x, 42, z, 4); + for (let x = 4; x <= 5; x++) for (let z = 4; z <= 5; z++) g.set(x, 41, z, 10); + for (let x = 3; x <= 6; x++) for (let z = 3; z <= 6; z++) { + g.set(x, 40, z, 10); + g.set(x, 39, z, (x + z) % 2 === 0 ? 21 : 10); // нижний пояс — светлее + } + g.set(4, 38, 4, 21); // язык колокола + return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 10: '#44454e', 21: '#56575f', 4: '#5c3b2e', 18: '#47474f' }); } -/** Старая Машина: тяжёлый блок механизма (prop 2×2 тайла, выше героя). */ +/** Старая Машина (редизайн): каменный цоколь шире корпуса, клёпаный + * металлический корпус со ржавыми швами, боковая труба с коленом к + * жерлу-дымоходу, топка с углями на фасаде, манометр-точка. */ export function machineModel(_seed: number): VoxelModel { const g = new VoxelGrid(24, 34, 18); - for (let y = 0; y < 22; y++) + // цоколь (тёмный камень с редкими светлыми камнями) + for (let y = 0; y < 2; y++) for (let z = 0; z < 18; z++) - for (let x = 0; x < 24; x++) - g.set(x, y, z, (x + z + y) % 4 === 0 ? 10 : 6); - for (let z = 6; z < 12; z++) - for (let x = 4; x < 20; x++) g.set(x, 22, z, 8); - for (let z = 6; z < 12; z++) g.set(12, 26, z, 10); - g.set(12, 30, 8, 9); // тёплая искра в жерле (жизнь!) - return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e', 9: '#d99a32' }); + for (let x = 0; x < 24; x++) g.set(x, y, z, (x + y * 2 + z) % 5 === 0 ? 6 : 18); + // корпус: клёпаный металл, швы ржавчиной через 5 рядов + for (let y = 2; y <= 20; y++) + for (let z = 1; z <= 16; z++) + for (let x = 1; x <= 22; x++) { + const edge = x === 1 || z === 1 || x === 22 || z === 16; + if (!edge) continue; + const seam = y % 5 === 0; + g.set(x, y, z, seam ? 8 : !seam && y % 4 === 2 && (x + z) % 4 === 1 ? 21 : 10); + } + // верхняя плита (ржавая) + for (let z = 3; z <= 14; z++) for (let x = 3; x <= 20; x++) g.set(x, 21, z, 8); + // труба с коленом: вверх по левой стенке, поворот к дымоходу + for (let y = 4; y <= 26; y++) for (const z of [3, 4]) g.set(2, y, z, 10); + for (let x = 2; x <= 11; x++) for (const z of [3, 4]) g.set(x, 26, z, 10); + // жерло-дымоход: ржавый верх, тёплая искра внутри (жизнь!) + for (let y = 27; y <= 31; y++) + for (let z = 3; z <= 6; z++) + for (let x = 10; x <= 13; x++) g.set(x, y, z, y >= 30 ? 8 : 10); + g.set(11, 30, 4, 9); + g.set(12, 30, 5, 9); + // топка на фасаде (z=16): ржавая рама, тёмный проём, угли + for (let y = 2; y <= 7; y++) + for (let x = 8; x <= 15; x++) { + const border = y === 7 || x === 8 || x === 15; + g.set(x, y, 16, border ? 8 : y <= 3 ? 9 : 18); + } + g.set(18, 12, 16, 9); // манометр (тёплая точка) + return encodeModel(g, { 6: '#5b5b66', 18: '#47474f', 10: '#44454e', 21: '#56575f', 8: '#82503a', 9: '#d99a32' }); } /** Бухта кабеля: низкая ржавая спираль (v1 tall 0.6 юнита). */ @@ -232,12 +455,14 @@ } /** Поставить штамп на тайл: модель центрируется на центре тайла - * (ствол/фундамент по центру, крона/крыша свисают за край — как в v1). */ + * (ствол/фундамент по центру, крона/крыша свисают за край — как в v1). + * Текстурный слой модели (тайли скинов) идёт в мир через реестр worldTex. */ function stampAt(world: VoxelWorld, m: VoxelModel, tx: number, ty: number): void { const [sx, , sz] = m.size; stampModel( world, m, tx * UNIT + 5 - ((sx - 1) >> 1), GROUND_Y, ty * UNIT + 5 - ((sz - 1) >> 1), + {}, { texRemap: texRemapFor(m) }, ); } @@ -312,15 +537,19 @@ } } -/** Штамп объекта тайла (дерево/дом/валун/куст/башня/колодец/рельсы...). */ +/** Штамп объекта тайла (дерево/дом/валун/куст/башня/колодец/рельсы...). + * Дома кэшируются по (тип, сид) — каждый дом со своим стилем; валунам и + * кустам вариант тоже выбирается сидом (в раскладке v1 только базовые + * тайлы 28/29 — вариативность живёт в сиде). */ function stampObject(world: VoxelWorld, t: number, tx: number, ty: number): void { const seed = tx * 131 + ty * 17; if (t in TREES) stampAt(world, cached(`tree${t}`, () => mapTreeModel(TREES[t]!, seed)), tx, ty); - else if (HOUSE_KINDS.includes(t)) stampAt(world, cached(`house${t}`, () => houseModel(t, seed)), tx, ty); + else if (HOUSE_KINDS.includes(t)) + stampAt(world, cached(`house${t}:${seed}`, () => houseModel(t, seed)), tx, ty); else if (BOULDER_KINDS.includes(t)) - stampAt(world, cached(`boul${t}`, () => mapBoulderModel((t - T.BOULDER) as Variant, seed)), tx, ty); + stampAt(world, cached(`boul${seed}`, () => mapBoulderModel(boulderVariant(seed), seed)), tx, ty); else if (BUSH_KINDS.includes(t)) - stampAt(world, cached(`bush${t}`, () => mapBushModel((t - T.BUSH) as Variant, seed)), tx, ty); + stampAt(world, cached(`bush${seed}`, () => mapBushModel(bushVariant(seed), seed)), tx, ty); else if (t === T.TOWER) stampAt(world, cached('tower', () => towerModel(seed)), tx, ty); else if (t === T.WELL) stampAt(world, cached('well', () => wellModel(seed)), tx, ty); else if (t === T.WIRE) stampAt(world, cached('wire', () => wireModel(seed)), tx, ty); @@ -330,6 +559,17 @@ else if (t === T.WALL) stampWall(world, tx, ty); } +/** Вариант валуна по сиду: 0/1/2 — крупность. */ +function boulderVariant(seed: number): Variant { + return (((seed ^ 0x4b0d) >>> 3) % 3) as Variant; +} + +/** Вариант куста по сиду: чаще обычный, реже высокий или малый. */ +function bushVariant(seed: number): Variant { + const h = ((seed ^ 0x5a51) >>> 5) % 8; + return (h === 7 ? 2 : h === 6 ? 1 : 0) as Variant; +} + /** Рельсы: металлическая нить по земле (полоса тайлов даёт линию). */ function stampRail(world: VoxelWorld, tx: number, ty: number): void { for (let d = 0; d < UNIT; d++) { diff --git a/v2/apps/game/src/deer.ts b/v2/apps/game/src/deer.ts index 5313e5c..bf4ab36 100644 --- a/v2/apps/game/src/deer.ts +++ b/v2/apps/game/src/deer.ts @@ -32,9 +32,11 @@ } /** - * Олень в мировых координатах: ноги на земле (GROUND_Y), высота ~10 вокселей - * (герой — 16). step — фаза шага (0/1), поднимает диагональные пары ног; - * dir — направление взгляда (см. rotXZ). + * Олень в мировых координатах (редизайн трека 29): ноги на земле + * (GROUND_Y), высота ~14 с рогами (герой — 16). Силуэт олений: грудь + * спереди выше, круп сзади, шея ступеньками вверх, голова с мордой и + * ушами, ветвистые рога, светлые пятна на боках. step — фаза шага (0/1), + * поднимает диагональные пары ног; dir — направление взгляда (см. rotXZ). */ export function deerVoxels(cx: number, cz: number, step: boolean, dir = 0): DeerVoxel[] { const out: DeerVoxel[] = []; @@ -42,26 +44,49 @@ const [rx, rz] = rotXZ(dx, dz, dir); out.push({ pos: [cx + rx, dy, cz + rz], color }); }; - // Ноги: диагональные пары (перед-лево + зад-право) поднимаются на шаге. + // Ноги 1×1 с копытами: диагональные пары (перед-лево + зад-право) + // поднимаются на шаге (бедро BODY, низ DARK). const lift = step ? 2 : 0; - for (const [lx, lz] of [[-4, -2], [4, 2], [-4, 2], [4, -2]] as const) { - const up = (lx === -4) === (lz === -2) ? lift : 0; // диагональ шагает - for (let y = 0; y < 4; y++) put(lx, GROUND_Y + y + up, lz, DARK); + for (const [lx, lz] of [[-5, -1], [4, 1], [-5, 1], [4, -1]] as const) { + const up = (lx === -5) === (lz === -1) ? lift : 0; // диагональ шагает + put(lx, GROUND_Y + up, lz, DARK); // копыто + put(lx, GROUND_Y + 1 + up, lz, DARK); + put(lx, GROUND_Y + 2 + up, lz, BODY); + put(lx, GROUND_Y + 3 + up, lz, BODY); } - // Туловище: 9×2×5, спина светлее. - for (let dx = -4; dx <= 4; dx++) + // Туловище 10×3×5: брюхо тёмное, бок пятнистый, спина светлая; + // круп сзади (x<0) на воксель выше груди — олений профиль. + for (let dx = -5; dx <= 4; dx++) for (let dz = -2; dz <= 2; dz++) { - put(dx, GROUND_Y + 4, dz, dx % 2 === 0 ? BODY : DARK); - put(dx, GROUND_Y + 5, dz, LIGHT); + put(dx, GROUND_Y + 4, dz, Math.abs(dz) === 2 ? DARK : BODY); // брюхо/бока + const side = (dx + dz) % 3 === 0 ? LIGHT : BODY; // пятна оленёнка + put(dx, GROUND_Y + 5, dz, side); + put(dx, GROUND_Y + 6, dz, LIGHT); // спина } - // Шея и голова: вперед-вверх, нос темный, уши-рожки. - put(4, GROUND_Y + 6, 0, BODY); - put(5, GROUND_Y + 6, 0, BODY); - put(5, GROUND_Y + 7, 0, DARK); // нос - put(5, GROUND_Y + 8, 0, LIGHT); - put(5, GROUND_Y + 9, -1, ANTLER); - put(5, GROUND_Y + 9, 1, ANTLER); + for (let dz = -1; dz <= 1; dz++) put(-5, GROUND_Y + 7, dz, LIGHT); // круп выше + // Шея ступеньками вверх от груди. + for (let dz = -1; dz <= 1; dz++) { + put(4, GROUND_Y + 7, dz, BODY); + put(5, GROUND_Y + 8, dz, BODY); + put(5, GROUND_Y + 9, dz, BODY); + } + // Голова 3×2×3: морда к +X, нос тёмный, уши в стороны. + for (let dx = 5; dx <= 6; dx++) + for (let dz = -1; dz <= 1; dz++) { + put(dx, GROUND_Y + 10, dz, BODY); + put(dx, GROUND_Y + 11, dz, LIGHT); + } + put(7, GROUND_Y + 10, 0, DARK); // нос + put(5, GROUND_Y + 11, -2, DARK); // уши + put(5, GROUND_Y + 11, 2, DARK); + // Рога ветвистые: ствол вверх, отросток в сторону. + for (const sz of [-1, 1] as const) { + put(5, GROUND_Y + 12, sz, ANTLER); + put(5, GROUND_Y + 13, sz, ANTLER); + put(6, GROUND_Y + 14, sz, ANTLER); + put(4, GROUND_Y + 13, sz * 2, ANTLER); // отросток + } // Хвост. - put(-5, GROUND_Y + 5, 0, LIGHT); + put(-6, GROUND_Y + 6, 0, LIGHT); return out; } \ No newline at end of file diff --git a/v2/apps/game/src/devview.ts b/v2/apps/game/src/devview.ts index 52fd8a1..59c2a6b 100644 --- a/v2/apps/game/src/devview.ts +++ b/v2/apps/game/src/devview.ts @@ -86,15 +86,26 @@ /** Природа/строения: генераторы карты по одному экземпляру. */ function worldEntries(): DevEntry[] { - const mk = (id: string, label: string, build: () => VoxelModel): DevEntry => ({ id, label, build: () => ({ model: build() }) }); + const mk = (id: string, label: string, build: () => VoxelModel): DevEntry => ({ + id, label, + build: () => { + const model = build(); + return { model, tiles: resolveTiles(model) }; // скины строений — текстурный слой + }, + }); return [ mk('tree0', 'Дерево — круглая крона', () => mapTreeModel(0, 11)), mk('tree1', 'Дерево — колонна', () => mapTreeModel(1, 22)), mk('tree2', 'Ель', () => mapTreeModel(2, 33)), mk('bush0', 'Куст', () => mapBushModel(0, 44)), mk('bush1', 'Куст высокий', () => mapBushModel(1, 55)), - mk('boulder', 'Валун', () => mapBoulderModel(0, 66)), - mk('house', 'Дом', () => houseModel(0, 0)), + mk('bush2', 'Куст малый', () => mapBushModel(2, 66)), + mk('boulder0', 'Валун — низкий', () => mapBoulderModel(0, 77)), + mk('boulder1', 'Валун — средний', () => mapBoulderModel(1, 88)), + mk('boulder2', 'Валун — крупный', () => mapBoulderModel(2, 99)), + mk('house', 'Дом — кладка', () => houseModel(0, 1)), + mk('house1', 'Дом — доски', () => houseModel(0, 4)), + mk('house2', 'Дом — фахверк, высокий', () => houseModel(26, 2)), // 26 = HOUSE_V3 (tall); сиды 1/4/2 — стили кладка/доски/фахверк mk('well', 'Колодец', () => wellModel(0)), mk('tower', 'Башня', () => towerModel(0)), mk('machine', 'Старая Машина', () => machineModel(0)), diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index c85dd68..6f5ea21 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -10,11 +10,11 @@ VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, DialogueRunner, Inventory, CutsceneRunner, validateCutscenes, - resolveTiles, collides, + resolveTiles, collides, worldTileAtlas, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueView, - DialogueEffectOp, Interactable, + DialogueEffectOp, Interactable, WorldTileAtlas, } from '@rpg/engine'; import * as THREE from 'three'; import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode, type AreaLayout } from './areas'; @@ -27,6 +27,7 @@ import { Interactables, type InteractSink } from './interactables'; import { type InteractableDef } from './data/interactables'; import { stampProp, clearProp } from './props'; +import { worldTiles as registryTiles } from './worldTex'; import { resolveCutscenes, type CutsceneAction } from './data/cutscenes'; import { FlowerQuest } from './flowers'; import { GameClock } from './clock'; @@ -61,6 +62,12 @@ 15: '#5a4a3c', // T0 — пол интерьеров 16: '#77634f', // T1 — тропа 17: '#61626c', // M2 — рельсы + 18: '#47474f', // P5 — тёмный камень (цоколь, трещины, проёмы) + 19: '#6e4a30', // R3 — дверное полотно (тёплое дерево) + 20: '#74553e', // R4 — доска стен / торцы брёвен + 21: '#56575f', // M3 — светлый металл (заклёпки, блики) + 22: '#3a4550', // W0 — тёмная вода (шахта колодца) + 23: '#5e6b4a', // G3 — мох на камнях (жизнь!) }, }; @@ -86,17 +93,36 @@ let world = new VoxelWorld(20); const chunkMeshes = new Map(); +// ---- Слой тайлей мира: общий атлас реестра worldTex ---- +// Слоты тайлей моделей локальны; при штамповке opts.texRemap (areas.ts) +// переводит их в глобальные id реестра. Материалы чанков сэмплируют ОДИН +// общий атлас (перестраивается после регистраций в модуле мира), его +// texture живёт вне материалов — диспозится только здесь, при пересборке. +const worldTiles = registryTiles; +let worldAtlas: WorldTileAtlas | null = null; + +/** Актуальный атлас реестра (null, пока тайлей нет — материалы плоские). */ +function currentWorldAtlas(): WorldTileAtlas | null { + if (!worldAtlas) worldAtlas = worldTileAtlas(worldTiles); + return worldAtlas; +} + function remeshDirty(): void { + const atlas = currentWorldAtlas(); for (const key of world.takeDirty()) { const [cx, cz] = key.split(',').map(Number); const old = chunkMeshes.get(key); if (old) { renderer.scene.remove(old); old.geometry.dispose(); + (old.material as THREE.MeshLambertMaterial).dispose(); // атлас общий — не диспозим } const mesh = meshChunk(world, cx, cz, PALETTE); - if (mesh.triangleCount > 0) chunkMeshes.set(key, renderer.addMesh(mesh)); - else chunkMeshes.delete(key); + if (mesh.triangleCount > 0) { + const obj = renderer.addMesh(mesh); + atlas?.applyTo(obj.material as THREE.MeshLambertMaterial); + chunkMeshes.set(key, obj); + } else chunkMeshes.delete(key); } } @@ -105,6 +131,7 @@ for (const m of chunkMeshes.values()) { renderer.scene.remove(m); m.geometry.dispose(); + (m.material as THREE.MeshLambertMaterial).dispose(); } chunkMeshes.clear(); } diff --git a/v2/apps/game/src/props.ts b/v2/apps/game/src/props.ts index 93ce1fc..6203deb 100644 --- a/v2/apps/game/src/props.ts +++ b/v2/apps/game/src/props.ts @@ -24,8 +24,8 @@ /** Поставить проп интерактива в его тайл (детерминированно, без ГПСЧ). */ export function stampProp(world: VoxelWorld, def: InteractableDef): void { if (!hasProp(def)) return; - const bx = def.tile.x * UNIT + 4; // центр тайла (10×10) - const bz = def.tile.y * UNIT + 4; + const bx = def.tile.x * UNIT + 5; // центр тайла (как tileCenter: tile*UNIT + 5) + const bz = def.tile.y * UNIT + 5; if (def.kind === 'pickup') { // мот: тёплая искра-столбик (видна издали) for (let y = 0; y < 3; y++) world.set(bx, GROUND_Y + y, bz, SPARK); diff --git a/v2/apps/game/src/worldTex.ts b/v2/apps/game/src/worldTex.ts new file mode 100644 index 0000000..92c9838 --- /dev/null +++ b/v2/apps/game/src/worldTex.ts @@ -0,0 +1,45 @@ +/** + * Реестр тайлей мира (трек 29, скины): слоты тайлей моделей локальны — + * при штамповке opts.texRemap переводит их в глобальные id 1..255 этого + * реестра. Тайли дедуплицируются по контенту: одинаковый пиксель-арт у + * разных моделей (домов с разными сидами, манекенов разных персонажей) + * занимает один id — реестр остаётся в пределах байта. + */ +import { resolveTiles } from '@rpg/engine'; +import type { CloudTiles, VoxelModel, ResolvedTile } from '@rpg/engine'; + +/** Глобальные тайли мира: id → разрешённый тайль (вход атласа рендера). */ +export const worldTiles: CloudTiles = {}; + +let nextId = 1; +const byKey = new Map(); // ключ контента тайля → глобальный id +const remapCache = new Map number>(); + +/** Ключ дедупликации: размер + грани (hex-пиксели resolveTiles). */ +function tileKey(t: ResolvedTile): string { + return `${t.size}|${t.faces.map((f) => f.join(',')).join(';')}`; +} + +/** Переводчик локальных id тайлей модели → глобальные (кэшируется по модели). */ +export function texRemapFor(model: VoxelModel): (localId: number) => number { + let m = remapCache.get(model); + if (!m) { + const local = new Map(); + for (const [k, t] of Object.entries(resolveTiles(model))) { + const id = Number(k); + if (id <= 0) continue; // 0 — плоский цвет, не тайль + const key = tileKey(t); + let gid = byKey.get(key); + if (gid === undefined) { + if (nextId > 255) throw new Error('Реестр тайлей мира переполнен (>255)'); + gid = nextId++; + worldTiles[gid] = t; + byKey.set(key, gid); + } + local.set(id, gid); + } + m = (lid) => local.get(lid) ?? 0; // незарегистрированный — плоский цвет + remapCache.set(model, m); + } + return m; +} \ No newline at end of file diff --git a/v2/apps/game/tools/checks/areas.mjs b/v2/apps/game/tools/checks/areas.mjs index beeafeb..f99c30b 100644 --- a/v2/apps/game/tools/checks/areas.mjs +++ b/v2/apps/game/tools/checks/areas.mjs @@ -146,7 +146,8 @@ await client.call('setInput', null, null); const hiStop = await client.call('heroInfo'); const gap = water.x - hiStop.pos[0]; // центр тела до центра воды - check('тело героя останавливается у грани воды', gap > 2.6 && gap < 4.5, + // UNIT 10: половина тайла (5) + радиус тела (~2.5) — граница воды + check('тело героя останавливается у грани воды', gap > 6.5 && gap < 8.5, { gap: Math.round(gap * 10) / 10 }); // Скриншоты областей: различаются и не пустые @@ -234,7 +235,7 @@ await client.call('stepTicks', 5); let ii = await client.call('interactInfo'); check('подсказка у двери: «E — Дом Ирвина»', ii.label === 'Дом Ирвина' && ii.hint.includes('E'), ii); - await client.call('heroTeleport', door.x + 30, door.z); // ~10 тайлов в стороне + await client.call('heroTeleport', door.x + 60, door.z); // 6 тайлов — вне зоны подсказки (UNIT 10) await client.call('stepTicks', 5); ii = await client.call('interactInfo'); check('вдали от двери подсказки нет', ii.label === null && ii.hint === '', ii); diff --git a/v2/apps/game/tools/checks/interact.mjs b/v2/apps/game/tools/checks/interact.mjs index 3677fe1..7a039dd 100644 --- a/v2/apps/game/tools/checks/interact.mjs +++ b/v2/apps/game/tools/checks/interact.mjs @@ -213,12 +213,13 @@ await client.call('dialogueAdvance'); // accept (флаг + custom → pendingHandIn) await client.call('dialogueAdvance'); // ring await client.call('dialogueAdvance'); // конец → onFinish → катсцена - await client.call('stepTicks', 84); // 0.8 с посадка + половина глайда + await client.call('stepTicks', 150); // посадка 0.8 с + глейд 1.2 с (сглаженная камера доезжает) const camMid = await client.call('heroInfo'); const heroMid = camMid.tile; - check('катсцена: герой стоит, камера глайдит к башне', - heroMid.x === 24 && heroMid.y === 22 && camMid.camTarget[0] > 78 - && camMid.camTarget[2] < 58, + check('катсцена: герой стоит, камера у башни', + heroMid.x === 24 && heroMid.y === 22 + && Math.abs(camMid.camTarget[0] - 285) < 3 && Math.abs(camMid.camTarget[2] - 145) < 3, + // UNIT 10: башня Звенца (28,14) → (285,145) { tile: heroMid, cam: camMid.camTarget }); await client.call('stepTicks', 260); // конец катсцены + возврат камеры const fin = await client.call('dialogueInfo'); diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index e12d509..fcc17fb 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -22,8 +22,8 @@ export { VoxelWorld } from './world/world'; export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; -export { VoxelRenderer } from './render/voxelRenderer'; -export type { CloudTiles } from './render/voxelRenderer'; +export { VoxelRenderer, worldTileAtlas } from './render/voxelRenderer'; +export type { CloudTiles, WorldTileAtlas } from './render/voxelRenderer'; export { ModelViewer, modelStats, orbitPosition } from './render/modelViewer'; export type { ModelStats, ModelViewerOptions } from './render/modelViewer'; export type { VoxelRendererOptions, VoxelCloud } from './render/voxelRenderer'; @@ -33,7 +33,7 @@ export { treeModel, boulderModel } from './models/generators'; export type { Variant } from './models/generators'; export { stampModel } from './models/stamp'; -export type { StampClip } from './models/stamp'; +export type { StampClip, StampOptions } from './models/stamp'; export { parseVox, exportVox } from './models/vox'; export { remapSlots } from './models/paletteMap'; export { mannequin, MANNEQUIN_BONES } from './models/mannequin'; diff --git a/v2/packages/engine/src/models/__tests__/mannequin.test.ts b/v2/packages/engine/src/models/__tests__/mannequin.test.ts index fd36ef8..b1187ed 100644 --- a/v2/packages/engine/src/models/__tests__/mannequin.test.ts +++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts @@ -37,7 +37,8 @@ expect(texIdAt(m.model, 0)).toBe(0); // 16 тайлей; тайль глаза (x3, y14 → id 5): нижний ряд арта — волос+глаз const resolved = resolveTiles(m.model); - expect(Object.keys(resolved).length).toBe(16); + // 16 лица + 3 скина (подол, застёжка, подошвы) — трек 29 + expect(Object.keys(resolved).length).toBe(19); expect(resolved[5]!.size).toBe(2); expect(resolved[5]!.faces[4]).toEqual(['#6e4a30', '#94949e', '#6e4a30', '#2a2624']); // перекраска палитрой красит и текстуры diff --git a/v2/packages/engine/src/models/__tests__/stampTex.test.ts b/v2/packages/engine/src/models/__tests__/stampTex.test.ts new file mode 100644 index 0000000..ac48455 --- /dev/null +++ b/v2/packages/engine/src/models/__tests__/stampTex.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { VoxelWorld } from '../../world/world'; +import { encodeModel } from '../format'; +import { VoxelGrid } from '../../voxel/grid'; +import { stampModel } from '../stamp'; + +/** Модель 1×1×1 с одним тайлем (id 3) на вокселе. */ +function texturedModel(): ReturnType { + const g = new VoxelGrid(1, 1, 1); + g.set(0, 0, 0, 1); + const face = btoa(String.fromCharCode(2, 2, 2, 2)); // size 2, слот 2 + return encodeModel(g, { 1: '#808080', 2: '#c0c0c0' }, { + tiles: { 3: { size: 2, faces: [face, face, face, face, face, face] } }, + tex: new Uint8Array([3]), + }); +} + +describe('stampModel со слоем тайлей (texRemap)', () => { + it('штампует мировой id тайля через переводчик', () => { + const w = new VoxelWorld(4); + const m = texturedModel(); + stampModel(w, m, 1, 0, 1, {}, { texRemap: (id: number) => id + 100 }); + expect(w.get(1, 0, 1)).toBe(1); + expect(w.getTex(1, 0, 1)).toBe(103); + }); + + it('без texRemap слой тайлей не пишется', () => { + const w = new VoxelWorld(4); + stampModel(w, texturedModel(), 0, 0, 0); + expect(w.get(0, 0, 0)).toBe(1); + expect(w.getTex(0, 0, 0)).toBe(0); + }); +}); diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts index 08be949..aa80ede 100644 --- a/v2/packages/engine/src/models/mannequin.ts +++ b/v2/packages/engine/src/models/mannequin.ts @@ -24,6 +24,7 @@ const HAIR = 5; // волосы const BELT = 9; // ремень const EYE = 10; // глаза и рот (тёмный, не зависит от ремня) +const METAL = 11; // пряжка ремня export const MANNEQUIN_BONES = ['root', 'legL', 'legR', 'armL', 'armR'] as const; export type MannequinBone = (typeof MANNEQUIN_BONES)[number]; @@ -46,6 +47,8 @@ belt?: string; /** Глаза и рот (по умолчанию тёмные — не зависят от ремня). */ eyes?: string; + /** Пряжка ремня (по умолчанию светлый металл). */ + metal?: string; } export interface Mannequin { @@ -78,6 +81,16 @@ return { size: 2, faces: [flat, flat, flat, flat, b64(block), flat] }; } +/** + * Тайль детали скина (трек 29): арт 2×2 текселей (строка 0 — верх грани) + * на +z (или на ВСЕ грани при all), прочие грани — плоский цвет слота. + */ +function skinTile(slot: number, art: number[], all = false): VoxelTile { + const flat = b64([slot, slot, slot, slot]); + const tex = b64(art); + return { size: 2, faces: all ? [tex, tex, tex, tex, tex, tex] : [flat, flat, flat, flat, tex, flat] }; +} + /** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */ export function mannequin(palette: MannequinPalette = {}): Mannequin { const g = new VoxelGrid(10, 16, 4); @@ -106,6 +119,7 @@ box(3, 6, 7, 7, 1, 3, BELT, 0); box(3, 6, 8, 9, 1, 3, TORSO, 0); box(2, 7, 10, 10, 1, 3, TORSO, 0); + put(4, 7, 3, METAL, 0); put(5, 7, 3, METAL, 0); // пряжка на поясе // руки: рукава (x1 и x8, z2) y7..10, кисти (кожа) y5..6 box(1, 1, 7, 10, 2, 2, ARM, 3); box(8, 8, 7, 10, 2, 2, ARM, 4); @@ -119,14 +133,24 @@ // фасад лица (+Z, z3): арт 8×8 текселей тайлями 2×2 const tiles: Record = {}; const tex = new Uint8Array(g.data.length); + const setTex = (x: number, y: number, z: number, id: number, tile: VoxelTile): void => { + tiles[id] = tile; + tex[x + g.sx * (y + g.sy * z)] = id; + }; for (let dy = 0; dy < 4; dy++) { for (let dx = 0; dx < 4; dx++) { const x = 3 + dx, y = 15 - dy; - const id = 1 + dy * 4 + dx; - tiles[id] = faceTile(g.get(x, y, 3), dx, dy); - tex[x + g.sx * (y + g.sy * 3)] = id; + setTex(x, y, 3, 1 + dy * 4 + dx, faceTile(g.get(x, y, 3), dx, dy)); } } + // скины деталей (трек 29): тёмная кромка подола, застёжка на груди, + // подошвы сапогов — тайли 2×2 + const hem = skinTile(TORSO, [TORSO, TORSO, TORSO, BELT]); + const clasp = skinTile(TORSO, [TORSO, BELT, TORSO, BELT]); + const sole = skinTile(BOOT, [BOOT, BOOT, EYE, EYE], true); + for (let x = 2; x <= 7; x++) for (let z = 1; z <= 3; z++) setTex(x, 5, z, 17, hem); + for (let y = 8; y <= 9; y++) for (let z = 1; z <= 3; z++) setTex(6, y, z, 18, clasp); + for (const [bx, bz] of [[3, 2], [3, 3], [6, 2], [6, 3]] as const) setTex(bx, 0, bz, 19, sole); const model = encodeModel(g, { 4: palette.torso ?? '#5c3b2e', 6: palette.legs ?? '#5b5b66', @@ -136,6 +160,7 @@ 5: palette.hair ?? '#6e4a30', 9: palette.belt ?? '#3a3438', 10: palette.eyes ?? '#2a2624', + 11: palette.metal ?? '#56575f', }, { tiles, tex }); const rig = makeRig( [ diff --git a/v2/packages/engine/src/models/stamp.ts b/v2/packages/engine/src/models/stamp.ts index c6d7d7e..2bfafe8 100644 --- a/v2/packages/engine/src/models/stamp.ts +++ b/v2/packages/engine/src/models/stamp.ts @@ -3,9 +3,11 @@ * Клиппинг: воксели вне вертикали мира и вне опционального прямоугольника * (half-open [min, max)) пропускаются. Возвращает число поставленных * вокселей; dirty-трекинг мира обычный (remeshDirty демки подхватит). + * С opts.texRemap текстурный слой модели (model.tex, id тайлей локальны) + * штампуется в мир через переводчик локальный id → мировой id. */ import { VoxelWorld } from '../world/world'; -import { decodeModel } from './format'; +import { decodeModel, texBytes } from './format'; import type { VoxelModel } from './format'; export interface StampClip { @@ -14,11 +16,17 @@ maxX?: number; maxY?: number; maxZ?: number; } +export interface StampOptions { + /** Переводчик id тайля модели → мировой id слоя тайлей мира. */ + texRemap?: (localId: number) => number; +} + export function stampModel( world: VoxelWorld, model: VoxelModel, x: number, y: number, z: number, - clip: StampClip = {}, + clip: StampClip = {}, opts: StampOptions = {}, ): number { const grid = decodeModel(model); + const tex = opts.texRemap ? texBytes(model) : null; 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) && @@ -32,6 +40,10 @@ const wx = x + lx, wy = y + ly, wz = z + lz; if (!inClip(wx, wy, wz)) continue; if (world.set(wx, wy, wz, slot)) placed++; + if (tex) { + const id = tex[lx + grid.sx * (ly + grid.sy * lz)]; + if (id !== 0) world.setTex(wx, wy, wz, opts.texRemap!(id)); + } } return placed; } \ No newline at end of file diff --git a/v2/packages/engine/src/render/__tests__/modelViewer.test.ts b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts index ff1b98f..bd926dc 100644 --- a/v2/packages/engine/src/render/__tests__/modelViewer.test.ts +++ b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts @@ -22,7 +22,8 @@ expect(s.size).toEqual([10, 16, 4]); expect(s.voxels).toBe(164); expect(s.bones).toBe(5); - expect(s.tiles).toBe(16); + // 16 тайлей лица + 3 скина (трек 29) + expect(s.tiles).toBe(19); // без риги — 0 костей expect(modelStats(model).bones).toBe(0); }); diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 45fd917..c253644 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -280,8 +280,10 @@ return { texture, texel, width }; } -/** Шейдерная инъция: uv сэмплинга атласа по грани куба (индекс из нормали). */ -function texturizeCloudMaterial(mat: THREE.MeshLambertMaterial, texel: number, width: number): void { +/** Шейдерная инъция: uv сэмплинга атласа по грани куба (индекс из нормали). + * Общая для облака (aTileId — инстансный атрибут) и world-меша (обычный + * атрибут из мешера + uv) — объявление атрибута одинаково. */ +function texturizeAtlasMaterial(mat: THREE.MeshLambertMaterial, texel: number, width: number): void { mat.onBeforeCompile = (shader) => { shader.uniforms.uTexel = { value: texel }; shader.uniforms.uAtlasWidth = { value: width }; @@ -303,6 +305,36 @@ }; } +/** + * Общий атлас тайлей мира: один texture на все чанки области (материалы + * чанков сэмплируют его через applyTo; texture дисползится ТОЛЬКО через + * dispose владельца — не из материала отдельного чанка). + */ +export interface WorldTileAtlas { + readonly texture: THREE.CanvasTexture; + /** Подключить атлас к материалу чанка (шейдерная инъция сэмплинга). */ + applyTo(mat: THREE.MeshLambertMaterial): void; + dispose(): void; +} + +/** + * Атлас реестра тайлей мира: мешер даёт per-vertex tiles/uvs (атрибуты + * aTileId и uv у BufferGeometry), сэмплинг — общий с облаком. Пустой + * реестр — null (материалы чанков остаются плоско-цветными). + */ +export function worldTileAtlas(tiles: CloudTiles): WorldTileAtlas | null { + if (!tiles || Object.keys(tiles).length === 0) return null; + const atlas = buildTileAtlas(tiles); + return { + texture: atlas.texture, + applyTo: (mat) => { + mat.map = atlas.texture; + texturizeAtlasMaterial(mat, atlas.texel, atlas.width); + }, + dispose: () => atlas.texture.dispose(), + }; +} + /** Фабрика облака (отдельная функция — рекурсия при росте ёмкости). */ function createCloud( scene: THREE.Scene, @@ -316,7 +348,7 @@ if (textured) { atlas = buildTileAtlas(tiles!); mat.map = atlas.texture; - texturizeCloudMaterial(mat, atlas.texel, atlas.width); + texturizeAtlasMaterial(mat, atlas.texel, atlas.width); } const mesh = new THREE.InstancedMesh(geo, mat, Math.max(1, voxels.length)); mesh.castShadow = true; diff --git a/v2/packages/engine/src/voxel/__tests__/mesher.test.ts b/v2/packages/engine/src/voxel/__tests__/mesher.test.ts index b47db14..c259be3 100644 --- a/v2/packages/engine/src/voxel/__tests__/mesher.test.ts +++ b/v2/packages/engine/src/voxel/__tests__/mesher.test.ts @@ -172,4 +172,29 @@ if (seg.join(',') === flip.join(',')) return flip; } return []; -} \ No newline at end of file +} +describe('текстурный слой мешера (texAt → tiles/uvs)', () => { + it('без texAt: tiles нулевые, uvs в пределах грани', () => { + const g = new VoxelGrid(1, 1, 1); + g.set(0, 0, 0, 1); + const m = meshVoxels(g, PALETTE); + expect(m.tiles).toEqual(new Array(m.vertexCount).fill(0)); + expect(m.uvs.length).toBe(m.vertexCount * 2); + for (let i = 0; i < m.uvs.length; i++) expect(m.uvs[i]).toBeGreaterThanOrEqual(0); + }); + + it('с texAt: 4 вершины грани несут один id тайля, uvs образуют квадрат 0..1', () => { + const g = new VoxelGrid(2, 1, 1); + g.set(0, 0, 0, 1); + g.set(1, 0, 0, 1); + const m = meshVoxels(g, PALETTE, { texAt: (x) => (x === 0 ? 5 : 0) }); + // у вокселя x=0 все 6 граней с тайлем 5, у x=1 — нули + let fives = 0, zeros = 0; + for (const t of m.tiles) (t === 5 ? fives++ : zeros++); + expect(fives).toBe(20); // 5 видимых граней × 4 вершины (общая срезана) + expect(zeros).toBe(20); + // uv-квадрат: (0,0),(1,0),(1,1),(0,1) — 4 вершины первой грани + const q = m.uvs.slice(0, 8); + expect(q).toEqual([0, 0, 0, 1, 1, 1, 1, 0]); // первая грань −x (ORDER_NEG) + }); +}); diff --git a/v2/packages/engine/src/voxel/mesher.ts b/v2/packages/engine/src/voxel/mesher.ts index abcd8b2..a38cdb8 100644 --- a/v2/packages/engine/src/voxel/mesher.ts +++ b/v2/packages/engine/src/voxel/mesher.ts @@ -24,6 +24,10 @@ indices: number[]; /** AO 0..3 на вершину (для тестов и отладки) */ ao: number[]; + /** id тайля на вершину (0 — плоский цвет; 4 вершины грани несут один) */ + tiles: number[]; + /** uv на вершину, 0..1 в пределах грани (сэмплинг атласа тайлей) */ + uvs: number[]; /** размер партий: вершин и треугольников (статистика) */ vertexCount: number; triangleCount: number; @@ -32,6 +36,8 @@ export interface MeshOptions { /** яркость по уровню AO (индекс = уровень occlusion 0..3); [1.0 — открыто] */ aoLevels?: [number, number, number, number]; + /** Доступ к слою тайлей (id тайля вокселя, 0 — без текстуры). */ + texAt?: (x: number, y: number, z: number) => number; } /** Функция доступа к цвету вокселя в мировых координатах (0 — пусто). */ @@ -87,6 +93,7 @@ }; const pos: number[] = [], nor: number[] = [], col: number[] = [], idx: number[] = [], ao: number[] = []; + const til: number[] = [], uvs: number[] = []; const solid = (x: number, y: number, z: number): number => (at(x, y, z) !== 0 ? 1 : 0); for (let z = bounds.z0; z < bounds.z1; z++) @@ -94,10 +101,10 @@ for (let x = bounds.x0; x < bounds.x1; x++) { const color = at(x, y, z); if (color === 0) continue; - emitVoxelFaces(x, y, z, color, rgb, levels, solid, pos, nor, col, idx, ao); + emitVoxelFaces(x, y, z, color, rgb, levels, solid, opts.texAt, pos, nor, col, idx, ao, til, uvs); } - return { positions: pos, normals: nor, colors: col, indices: idx, ao, vertexCount: ao.length, triangleCount: idx.length / 3 }; + return { positions: pos, normals: nor, colors: col, indices: idx, ao, tiles: til, uvs, vertexCount: ao.length, triangleCount: idx.length / 3 }; } type RgbFn = (color: number) => [number, number, number]; @@ -107,8 +114,11 @@ function emitVoxelFaces( x: number, y: number, z: number, color: number, rgb: RgbFn, levels: [number, number, number, number], solid: SolidFn, + texAt: ((x: number, y: number, z: number) => number) | undefined, pos: number[], nor: number[], col: number[], idx: number[], aoOut: number[], + tilOut: number[], uvOut: number[], ): void { + const tile = texAt ? texAt(x, y, z) : 0; // тайль один на воксель (6 суб-граней в атласе) for (const { axis, sign } of FACES) { const nx = axis === 0 ? x + sign : x; const ny = axis === 1 ? y + sign : y; @@ -128,6 +138,8 @@ 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); + tilOut.push(tile); + uvOut.push(du, dv); // AO: два боковых соседа + диагональный, в плоскости соседней клетки const offU = du === 1 ? 1 : -1; diff --git a/v2/packages/engine/src/world/__tests__/world.test.ts b/v2/packages/engine/src/world/__tests__/world.test.ts index 683daa1..0216a12 100644 --- a/v2/packages/engine/src/world/__tests__/world.test.ts +++ b/v2/packages/engine/src/world/__tests__/world.test.ts @@ -100,4 +100,25 @@ // 2 вокселя × 6 граней − интерфейсная пара (у каждого свой квад) = 10 граней expect((a.vertexCount + b.vertexCount) / 4).toBe(10); }); -}); \ No newline at end of file +}); +describe('слой тайлей мира (tex)', () => { + it('setTex/getTex: параллельный слой, dirty как у set, вне мира — false', () => { + const w = new VoxelWorld(4); + expect(w.getTex(3, 1, 3)).toBe(0); + w.set(3, 1, 3, 1); + expect(w.setTex(3, 1, 3, 7)).toBe(true); + expect(w.getTex(3, 1, 3)).toBe(7); + expect(w.dirtyChunks().length).toBeGreaterThan(0); + expect(w.setTex(0, 4, 0, 1)).toBe(false); // вне вертикали + expect(w.getTex(3, 1, 0)).toBe(0); // несуществующий чанк + }); + + it('meshChunk подхватывает слой тайлей мира', () => { + const w = new VoxelWorld(4); + w.set(0, 1, 0, 1); + w.setTex(0, 1, 0, 9); + const m = meshChunk(w, 0, 0, PALETTE); + expect(m.tiles.every((t) => t === 9)).toBe(true); + expect(m.uvs.length).toBe(m.vertexCount * 2); + }); +}); diff --git a/v2/packages/engine/src/world/chunk.ts b/v2/packages/engine/src/world/chunk.ts index 72a4210..d34ea75 100644 --- a/v2/packages/engine/src/world/chunk.ts +++ b/v2/packages/engine/src/world/chunk.ts @@ -11,6 +11,8 @@ readonly cz: number; readonly sy: number; readonly data: Uint8Array; + /** Слой тайлей (id тайля грани, 0 — плоский цвет), параллелен data. */ + readonly tex: Uint8Array; constructor(cx: number, cz: number, sy = 64) { if (sy <= 0) throw new Error(`Chunk: высота ${sy} должна быть положительной`); @@ -18,6 +20,7 @@ this.cz = cz; this.sy = sy; this.data = new Uint8Array(CHUNK_SX * sy * CHUNK_SZ); + this.tex = new Uint8Array(CHUNK_SX * sy * CHUNK_SZ); } inside(lx: number, y: number, lz: number): boolean { @@ -39,6 +42,19 @@ this.data[this.idx(lx, y, lz)] = color; } + /** Id тайля вокселя (0 — плоский цвет; вне чанка — 0). */ + getTex(lx: number, y: number, lz: number): number { + if (!this.inside(lx, y, lz)) return 0; + return this.tex[this.idx(lx, y, lz)]; + } + + /** Поставить id тайля (0 — снять текстуру). */ + setTex(lx: number, y: number, lz: number, tile: number): void { + if (!this.inside(lx, y, lz)) return; + if (tile < 0 || tile > 255) throw new Error(`Chunk.setTex: тайль ${tile} вне 0..255`); + this.tex[this.idx(lx, y, lz)] = tile; + } + isEmpty(lx: number, y: number, lz: number): boolean { return this.get(lx, y, lz) === 0; } diff --git a/v2/packages/engine/src/world/world.ts b/v2/packages/engine/src/world/world.ts index 7ec1ce0..f62ad90 100644 --- a/v2/packages/engine/src/world/world.ts +++ b/v2/packages/engine/src/world/world.ts @@ -69,6 +69,29 @@ return true; } + /** Id тайля вокселя в мире (0 — плоский цвет; несуществующий чанк — 0). */ + getTex(x: number, y: number, z: number): number { + const { cx, cz } = this.chunkCoords(x, z); + const c = this.chunkAt(cx, cz); + if (!c) return 0; + return c.getTex(x - cx * CHUNK_SX, y, z - cz * CHUNK_SZ); + } + + /** + * Поставить id тайля вокселя (0 — снять текстуру). Слой параллелен + * цвету; dirty-трекинг как у set (меш с текстурами перестраивается + * тем же ремешингом). + */ + setTex(x: number, y: number, z: number, tile: number): boolean { + if (y < 0 || y >= this.chunkHeight) return false; + const { cx, cz } = this.chunkCoords(x, z); + const c = this.ensureChunk(cx, cz); + const lx = x - cx * CHUNK_SX, lz = z - cz * CHUNK_SZ; + c.setTex(lx, y, lz, tile); + this.dirty.add(VoxelWorld.key(cx, cz)); + return true; + } + /** Список изменённых чанков (ключи «cx,cz») без очистки. */ dirtyChunks(): string[] { return [...this.dirty]; diff --git a/v2/packages/engine/src/world/worldMesher.ts b/v2/packages/engine/src/world/worldMesher.ts index 66c48ff..66d94d8 100644 --- a/v2/packages/engine/src/world/worldMesher.ts +++ b/v2/packages/engine/src/world/worldMesher.ts @@ -17,7 +17,13 @@ }; } -/** Меш одного чанка; соседи берутся из мира (шов бесшовный). */ +/** Меш одного чанка; соседи берутся из мира (шов бесшовный). Слой тайлей — + * из мира (opts.texAt переопределяет). */ export function meshChunk(world: VoxelWorld, cx: number, cz: number, palette: VoxelPalette, opts: MeshOptions = {}): VoxelMesh { - return meshRegion((x, y, z) => world.get(x, y, z), chunkBounds(cx, cz, world.chunkHeight), palette, opts); + return meshRegion( + (x, y, z) => world.get(x, y, z), + chunkBounds(cx, cz, world.chunkHeight), + palette, + { ...opts, texAt: opts.texAt ?? ((x, y, z) => world.getTex(x, y, z)) }, + ); } \ No newline at end of file