Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
250 lines
12 KiB
JavaScript
250 lines
12 KiB
JavaScript
/**
|
||
* SmoothBrushes — кисти для редактирования гладкого ландшафта (DensityGrid).
|
||
*
|
||
* Все операции работают со СФЕРОЙ в мировых координатах + строгая привязка
|
||
* к density-grid (4м/cell). Возвращают Set<chunkKey> затронутых чанков,
|
||
* чтобы 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<string>} 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();
|
||
}
|
||
}
|