/** * SmoothBrushes — кисти для редактирования гладкого ландшафта (DensityGrid). * * Все операции работают со СФЕРОЙ в мировых координатах + строгая привязка * к density-grid (4м/cell). Возвращают Set затронутых чанков, * чтобы RobloxTerrain пересобрал только их (а не всю карту). * * Поддерживаемые кисти (как в Roblox Studio): * - sculptUp: добавить материал (поднять рельеф). Density += strength. * - sculptDown: убрать материал (вырыть). Density -= strength. * - smooth: усреднить density с соседями (laplacian filter). * - paint: заменить материал в solid-ячейках, density не меняем. * - flatten: выровнять по плоскости Y=target — поднять/опустить density. * - fill: залить сферу density=255 материалом. * - erase: обнулить density в сфере. */ import { CELL_SIZE, DENSITY_THRESHOLD } from './DensityGrid'; /** Размер chunk'а — должен совпадать с RobloxTerrain.CHUNK_SIZE. */ const CHUNK_SIZE = 16; function worldToCell(w) { return Math.floor(w / CELL_SIZE); } /** Ключ chunk'а из LOCAL cell-индекса (внутри grid). */ function localCellToChunkKey(lx, ly, lz) { const cx = Math.floor(lx / CHUNK_SIZE); const cy = Math.floor(ly / CHUNK_SIZE); const cz = Math.floor(lz / CHUNK_SIZE); return `${cx},${cy},${cz}`; } /** * Перебор cells в сфере с smoothstep-влиянием. * grid.origin — global origin. Координаты gx,gy,gz в callback — LOCAL индексы * (внутри grid 0..size). Используем local чтобы grid.set / grid.getDensity * работали правильно. */ function forCellsInSphere(grid, center, radius, callback) { const dirty = new Set(); const r2 = radius * radius; // bbox в LOCAL индексах с запасом +1 const minGxL = worldToCell(center.x - radius) - grid.origin.x - 1; const maxGxL = worldToCell(center.x + radius) - grid.origin.x + 1; const minGyL = worldToCell(center.y - radius) - grid.origin.y - 1; const maxGyL = worldToCell(center.y + radius) - grid.origin.y + 1; const minGzL = worldToCell(center.z - radius) - grid.origin.z - 1; const maxGzL = worldToCell(center.z + radius) - grid.origin.z + 1; for (let gzL = minGzL; gzL <= maxGzL; gzL++) { for (let gyL = minGyL; gyL <= maxGyL; gyL++) { for (let gxL = minGxL; gxL <= maxGxL; gxL++) { // Центр cell в мире const wx = (grid.origin.x + gxL + 0.5) * CELL_SIZE; const wy = (grid.origin.y + gyL + 0.5) * CELL_SIZE; const wz = (grid.origin.z + gzL + 0.5) * CELL_SIZE; const dx = wx - center.x; const dy = wy - center.y; const dz = wz - center.z; const d2 = dx * dx + dy * dy + dz * dz; if (d2 > r2) continue; const t = Math.sqrt(d2) / radius; const influence = 1 - t * t * (3 - 2 * t); const changed = callback(gxL, gyL, gzL, influence); if (changed) dirty.add(localCellToChunkKey(gxL, gyL, gzL)); } } } return dirty; } /** Sculpt Up — добавляет density (поднять / нарастить). * Важный фикс для низкой силы: если cell ПУСТАЯ (oldD<10) и попала в зону * влияния (inf > 0.1) — сразу делаем density >= threshold (128). Иначе * низкая strength не давала cell достичь порога видимости → пользователь * кликал-кликал, density рос на 1-2 за клик, но визуально ничего не было * пока 128 не достиг (≈40 кликов в одну cell). * С прыжком до threshold — первый клик СРАЗУ показывает результат, дальше * растёт линейно от strength. */ export function brushSculptUp(grid, params) { const { center, radius, strength = 80, material = 'grass' } = params; // strength → нормализованная sNorm 0..1 (60..400 → 0..1). const sNorm = Math.max(0, Math.min(1, (strength - 60) / 340)); // === Эффективный радиус: основа влияния силы === // sNorm=0 → effectiveRadius = 0.35×radius (маленький бугорок) // sNorm=1 → effectiveRadius = 1.0×radius (полный шар) // Это даёт реальное визуальное отличие — холм РАЗНОЙ ВЫСОТЫ: // слабая сила — короткий бугор, сильная — полный купол. const effRadius = radius * (0.35 + sNorm * 0.65); return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => { // Вычисляем РАССТОЯНИЕ cell-центра до центра кисти (в метрах). // Если оно > effRadius — пропускаем (cell вне effective-зоны). const wx = (grid.origin.x + gx + 0.5) * CELL_SIZE; const wy = (grid.origin.y + gy + 0.5) * CELL_SIZE; const wz = (grid.origin.z + gz + 0.5) * CELL_SIZE; const dx = wx - center.x; const dy = wy - center.y; const dz = wz - center.z; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); if (dist > effRadius) return false; // Внутри effRadius — плавный градиент density: центр=255, край=threshold. const t = dist / effRadius; const target = 255 - (255 - DENSITY_THRESHOLD) * t * t; // мягкий falloff const oldD = grid.getDensity(gx, gy, gz); if (oldD >= target) return false; const newD = Math.round(target); if (newD === oldD) return false; const curMat = grid.getMatId(gx, gy, gz); const matKey = curMat === 0 ? material : null; grid.set(gx, gy, gz, newD, matKey); return true; }); } /** Sculpt Down — симметрично sculptUp: effective radius растёт с силой. */ export function brushSculptDown(grid, params) { const { center, radius, strength = 80 } = params; const sNorm = Math.max(0, Math.min(1, (strength - 60) / 340)); const effRadius = radius * (0.35 + sNorm * 0.65); return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => { const wx = (grid.origin.x + gx + 0.5) * CELL_SIZE; const wy = (grid.origin.y + gy + 0.5) * CELL_SIZE; const wz = (grid.origin.z + gz + 0.5) * CELL_SIZE; const dx = wx - center.x; const dy = wy - center.y; const dz = wz - center.z; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); if (dist > effRadius) return false; const oldD = grid.getDensity(gx, gy, gz); if (oldD === 0) return false; // Center=0, край=threshold-2 (мягкий край ямы). const t = dist / effRadius; const target = 0 + (DENSITY_THRESHOLD - 2) * t * t; if (oldD <= target) return false; const newD = Math.max(0, Math.round(target)); if (newD === oldD) return false; grid.set(gx, gy, gz, newD, null); return true; }); } /** Smooth — усредняет density с соседями. */ export function brushSmooth(grid, params) { const { center, radius, strength = 50 } = params; const updates = []; forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => { const curD = grid.getDensity(gx, gy, gz); const sumD = grid.getDensity(gx - 1, gy, gz) + grid.getDensity(gx + 1, gy, gz) + grid.getDensity(gx, gy - 1, gz) + grid.getDensity(gx, gy + 1, gz) + grid.getDensity(gx, gy, gz - 1) + grid.getDensity(gx, gy, gz + 1); const avgD = sumD / 6; const blend = Math.min(1, (strength / 255) * inf); const newD = Math.round(curD * (1 - blend) + avgD * blend); if (newD !== curD) updates.push([gx, gy, gz, newD]); return false; }); const dirty = new Set(); for (const [gx, gy, gz, newD] of updates) { grid.set(gx, gy, gz, newD, null); dirty.add(localCellToChunkKey(gx, gy, gz)); } return dirty; } /** Paint — меняет материал в solid-ячейках, density не трогаем. */ export function brushPaint(grid, params) { const { center, radius, material = 'grass' } = params; return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => { const d = grid.getDensity(gx, gy, gz); if (d < DENSITY_THRESHOLD) return false; const curKey = grid.getMatKey(gx, gy, gz); if (curKey === material) return false; grid.set(gx, gy, gz, d, material); return true; }); } /** Flatten — выровнять поверхность по плоскости Y=targetY. */ export function brushFlatten(grid, params) { const { center, radius, strength = 80, material = 'grass', targetY = center.y } = params; return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => { const cellY = (grid.origin.y + gy + 0.5) * CELL_SIZE; const oldD = grid.getDensity(gx, gy, gz); let newD; if (cellY < targetY - CELL_SIZE * 0.5) { newD = Math.min(255, oldD + strength * inf); } else if (cellY > targetY + CELL_SIZE * 0.5) { newD = Math.max(0, oldD - strength * inf); } else { const target = DENSITY_THRESHOLD; const delta = (target - oldD) * inf * (strength / 255); newD = Math.round(Math.max(0, Math.min(255, oldD + delta))); } newD = newD | 0; if (newD === oldD) return false; const curMat = grid.getMatId(gx, gy, gz); const matKey = curMat === 0 ? material : null; grid.set(gx, gy, gz, newD, matKey); return true; }); } /** Fill — жёстко заполняет сферу solid-density (255). */ export function brushFill(grid, params) { const { center, radius, material = 'grass' } = params; return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => { if (inf < 0.05) return false; const oldD = grid.getDensity(gx, gy, gz); const curKey = grid.getMatKey(gx, gy, gz); if (oldD === 255 && curKey === material) return false; grid.set(gx, gy, gz, 255, material); return true; }); } /** Erase — обнуляет density. */ export function brushErase(grid, params) { const { center, radius } = params; return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => { const oldD = grid.getDensity(gx, gy, gz); if (oldD === 0) return false; const newD = Math.max(0, oldD - 255 * inf) | 0; if (newD === oldD) return false; grid.set(gx, gy, gz, newD, null); return true; }); } /** * Главный API — диспетчер по типу кисти. * @returns {Set} dirty chunks */ export function applyBrush(grid, brushType, params) { switch (brushType) { case 'sculptUp': return brushSculptUp(grid, params); case 'sculptDown': return brushSculptDown(grid, params); case 'smooth': return brushSmooth(grid, params); case 'paint': return brushPaint(grid, params); case 'flatten': return brushFlatten(grid, params); case 'fill': return brushFill(grid, params); case 'erase': return brushErase(grid, params); default: console.warn('[SmoothBrushes] unknown brush:', brushType); return new Set(); } }