Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026). Содержит изменения которые делались в процессе подготовки прод-окружения: Фиксы импортов после выноса из minecraftia: - Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/) - Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/ - API.js скопирован из минки целиком (было 8 экспортов, стало 312) - Добавлены PLAYER_URL, MyButton_1, недостающие компоненты - Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require) Структура ассетов: - public/kubikon-templates/ → public/assets/kubikon-templates/ - public/kubikon-learn/ → public/assets/kubikon-learn/ - (код искал в /assets/, файлы лежали без /assets/) Навигация роутов внутри студии: - /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced) - /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X UI: - Новый компонент StudioHeader (61px, как в минке) + копия favicon - WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера - SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке) - Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык) Документация: - docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR - docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API - API_USAGE.md — список эндпоинтов backend - README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/ .gitignore: - public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
389 lines
18 KiB
JavaScript
389 lines
18 KiB
JavaScript
/**
|
||
* GdForest — расстановка декораций ландшафта в GD-уровне (этап G6 v2).
|
||
*
|
||
* Загружает GLB-модели из gd_deco_choices (выбор юзера в /admin-preview/gd-deco)
|
||
* для текущей эпохи, расставляет их вдоль уровня случайно из выбранного набора.
|
||
*
|
||
* - Дальние позиции (z=8..22) — крупные деревья/камни.
|
||
* - Ближние (z=-3..-7) — мелкие, мешают видимости меньше.
|
||
* - Через thin-instances: 1 draw call на тип модели.
|
||
*/
|
||
import {
|
||
Mesh, MeshBuilder, SceneLoader, Matrix, Quaternion, Vector3, Color3,
|
||
StandardMaterial, DynamicTexture, VertexData, Ray,
|
||
} from '@babylonjs/core';
|
||
import '@babylonjs/loaders/glTF';
|
||
import { DECO_CATALOG } from '../../admin-preview/gdDeco/decoFactories';
|
||
import { RobloxTerrain } from './robloxterrain/RobloxTerrain';
|
||
import { DensityGrid, CELL_SIZE } from './robloxterrain/DensityGrid';
|
||
import { applyBrush } from './robloxterrain/SmoothBrushes';
|
||
|
||
const ASSET_ROOT = '/kubikon-assets/models/nature-kit/';
|
||
|
||
// Дефолтный выбор по эпохе (выбор юзера сохранён в gd_deco_choices).
|
||
const DEFAULT_DECO_BY_EPOCH = {
|
||
1: ['d1_v1', 'd1_v2', 'd1_v5', 'd1_v6', 'd1_v10'],
|
||
2: ['d2_v1', 'd2_v3', 'd2_v5', 'd2_v7', 'd2_v9'], // 5 видов сосен
|
||
3: ['d3_v8', 'd3_v10', 'd3_v7', 'd3_v4', 'd3_v5'],
|
||
4: ['d4_v4', 'd4_v1', 'd4_v2', 'd4_v8', 'd4_v7'],
|
||
5: ['d5_v4', 'd5_v2', 'd5_v1', 'd5_v8', 'd5_v5'],
|
||
6: ['d6_v5', 'd6_v6', 'd6_v8', 'd6_v9', 'd6_v10'],
|
||
7: ['d7_v3', 'd7_v4', 'd7_v5', 'd7_v9', 'd7_v10'],
|
||
8: ['d8_v5', 'd8_v4', 'd8_v3', 'd8_v2', 'd8_v9'],
|
||
9: ['d9_v5', 'd9_v4', 'd9_v3', 'd9_v2', 'd9_v1'],
|
||
};
|
||
|
||
// Кол-во инстансов на тип модели — только дальний план, перед камерой пусто.
|
||
// В low-quality режиме уменьшаем в 2 раза для FPS.
|
||
const _quality = (typeof localStorage !== 'undefined' && localStorage.getItem('gd_graphics_quality')) || 'high';
|
||
const FAR_TOTAL = _quality === 'low' ? 40 : 80;
|
||
const FAR_Z_MIN = 8;
|
||
const FAR_Z_MAX = 22;
|
||
// Y базы — корни врастают ниже верха пола (отрицательное Y), чтобы дерево
|
||
// сидело глубже в траве и не «парило».
|
||
const BASE_Y = -0.5;
|
||
// Глобальный коэффициент масштаба (уменьшение в 1.4 раза от каталога).
|
||
const SCALE_MULT = 0.7;
|
||
|
||
export class GdForest {
|
||
constructor() {
|
||
this.scene = null;
|
||
this._levelWidth = 1000;
|
||
this._protos = []; // [{ mesh, items }]
|
||
this._onBeforeRender = null;
|
||
this._t = 0;
|
||
}
|
||
|
||
/**
|
||
* @param scene Babylon scene
|
||
* @param levelWidth ширина уровня в метрах
|
||
* @param epoch номер эпохи (1..10)
|
||
* @param decoIds массив id моделей (если не задан — берём DEFAULT_DECO_BY_EPOCH)
|
||
*/
|
||
async attach(scene, levelWidth = 1000, epoch = 1, decoIds = null) {
|
||
if (!scene) return;
|
||
this.scene = scene;
|
||
this._levelWidth = levelWidth;
|
||
this._epoch = epoch;
|
||
const ids = decoIds || DEFAULT_DECO_BY_EPOCH[epoch] || [];
|
||
if (ids.length === 0) {
|
||
console.log('[GdForest] нет выбранных декораций для эпохи', epoch);
|
||
return;
|
||
}
|
||
// Загружаем все GLB параллельно
|
||
const loaded = await Promise.all(ids.map((id) => this._loadDecoProto(id)));
|
||
const valid = loaded.filter((x) => x && x.proto);
|
||
if (valid.length === 0) {
|
||
console.warn('[GdForest] ни одна модель не загрузилась');
|
||
return;
|
||
}
|
||
this._protos = valid;
|
||
this._createForestGround();
|
||
this._distributeInstances();
|
||
this._setupWind();
|
||
console.log(`[GdForest] загружено ${valid.length}/${ids.length} моделей для эпохи ${epoch}`);
|
||
}
|
||
|
||
/** Гладкий 3D-ландшафт «лесная поляна» через RobloxTerrain (как в редакторе).
|
||
* Скульптим холмы вдоль трассы и просим RobloxTerrain построить все chunks. */
|
||
_createForestGround() {
|
||
// === 1. DensityGrid под лесную полосу за трассой ===
|
||
const Z_NEAR = FAR_Z_MIN - 4;
|
||
const Z_FAR = FAR_Z_MAX + 8;
|
||
const xCells = Math.ceil((this._levelWidth + 60) / CELL_SIZE);
|
||
const zCells = Math.ceil((Z_FAR - Z_NEAR) / CELL_SIZE);
|
||
const yCells = 8;
|
||
const originX = Math.floor((-30) / CELL_SIZE);
|
||
const originY = Math.floor(-12 / CELL_SIZE);
|
||
const originZ = Math.floor(Z_NEAR / CELL_SIZE);
|
||
const grid = new DensityGrid({
|
||
origin: { x: originX, y: originY, z: originZ },
|
||
size: { x: xCells, y: yCells, z: zCells },
|
||
});
|
||
// === 2. Создаём RobloxTerrain и используем applyBrushAndRebuild ===
|
||
const rt = new RobloxTerrain(this.scene);
|
||
rt.loadFromGrid(grid, { skipEmpty: true });
|
||
const Z_CENTER = (FAR_Z_MIN + FAR_Z_MAX) / 2;
|
||
// Материал ландшафта по эпохе (соответствует TERRAIN_MATERIALS).
|
||
const TERRAIN_MAT_BY_EPOCH = {
|
||
1: 'grass', 2: 'snow', 3: 'grass', 4: 'grass',
|
||
5: 'sand', 6: 'sand', 7: 'rock', 8: 'rock', 9: 'rock', 10: 'rock',
|
||
};
|
||
const tMat = TERRAIN_MAT_BY_EPOCH[this._epoch] || 'grass';
|
||
// Базовый «толстый слой грунта» вдоль всей полосы
|
||
for (let x = -20; x <= this._levelWidth + 20; x += 12) {
|
||
rt.applyBrushAndRebuild('sculptUp', {
|
||
center: { x, y: -2, z: Z_CENTER },
|
||
radius: 12, strength: 400, material: tMat,
|
||
});
|
||
}
|
||
// Холмы повыше — реже, разной высоты
|
||
const numHills = Math.floor(this._levelWidth / 25);
|
||
for (let i = 0; i < numHills; i++) {
|
||
const x = (i + 0.5) * 25 + Math.sin(i * 12.9) * 6;
|
||
const z = Z_CENTER + Math.sin(i * 7.7) * 4;
|
||
const yPeak = 1 + Math.abs(Math.sin(i * 3.3)) * 3;
|
||
rt.applyBrushAndRebuild('sculptUp', {
|
||
center: { x, y: yPeak, z },
|
||
radius: 10 + Math.abs(Math.sin(i * 2.1)) * 4,
|
||
strength: 350, material: tMat,
|
||
});
|
||
}
|
||
// Сглаживание
|
||
for (let x = -20; x <= this._levelWidth + 20; x += 18) {
|
||
rt.applyBrushAndRebuild('smooth', {
|
||
center: { x, y: 0, z: Z_CENTER },
|
||
radius: 18, strength: 200,
|
||
});
|
||
}
|
||
this._robloxTerrain = rt;
|
||
console.log('[GdForest] RobloxTerrain создан, chunks=', rt.chunks?.size);
|
||
}
|
||
|
||
/** Canvas-текстура: трава/земля с цветами/пятнами. Тайлится. */
|
||
_makeForestGroundTexture() {
|
||
const W = 512, H = 256;
|
||
const dt = new DynamicTexture('gd_forest_ground_tex', { width: W, height: H }, this.scene, true);
|
||
const ctx = dt.getContext();
|
||
// Базовый зелёный (с лёгким noise)
|
||
for (let y = 0; y < H; y += 4) {
|
||
for (let x = 0; x < W; x += 4) {
|
||
const r = Math.abs((Math.sin(x * 0.13 + y * 0.21) * 43758)) % 1;
|
||
const g = 90 + Math.floor(r * 50);
|
||
const rd = 35 + Math.floor(r * 25);
|
||
ctx.fillStyle = `rgb(${rd},${g},45)`;
|
||
ctx.fillRect(x, y, 4, 4);
|
||
}
|
||
}
|
||
// Тёмные пятна — мох / земля
|
||
for (let i = 0; i < 60; i++) {
|
||
const x = Math.abs((Math.sin(i * 12.9898) * 43758)) % W;
|
||
const y = Math.abs((Math.sin(i * 78.233 + 1) * 43758)) % H;
|
||
const r = 25 + (i % 7) * 6;
|
||
const grad = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||
grad.addColorStop(0, 'rgba(50,30,15,0.45)');
|
||
grad.addColorStop(1, 'rgba(50,30,15,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
// Светлые травянистые пятна
|
||
for (let i = 0; i < 80; i++) {
|
||
const x = Math.abs((Math.sin(i * 17.1 + 0.7) * 43758)) % W;
|
||
const y = Math.abs((Math.sin(i * 91.3 + 1.1) * 43758)) % H;
|
||
const r = 12 + (i % 5) * 4;
|
||
const grad = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||
grad.addColorStop(0, 'rgba(120,200,80,0.55)');
|
||
grad.addColorStop(1, 'rgba(120,200,80,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
dt.update();
|
||
// Тайлим по X много раз — длина уровня большая
|
||
dt.wrapU = 1; dt.wrapV = 1;
|
||
dt.uScale = Math.ceil(this._levelWidth / 30);
|
||
dt.vScale = 1;
|
||
return dt;
|
||
}
|
||
|
||
/** Загрузить один прото-меш по id из DECO_CATALOG.
|
||
* Логика как в SmoothDecoManager: bake parent-chain (root scale/rotation
|
||
* из glTF) → merge → центрирование bbox. После этого инстансы можно
|
||
* ставить через простую TRS матрицу. */
|
||
async _loadDecoProto(id) {
|
||
const def = DECO_CATALOG.find((d) => d.id === id);
|
||
if (!def) { console.warn('[GdForest] неизвестный deco-id:', id); return null; }
|
||
try {
|
||
const container = await SceneLoader.LoadAssetContainerAsync(
|
||
ASSET_ROOT, def.file, this.scene,
|
||
);
|
||
container.addAllToScene();
|
||
const allMeshes = container.meshes.slice();
|
||
const geoMeshes = allMeshes.filter(
|
||
(m) => m.getTotalVertices && m.getTotalVertices() > 0,
|
||
);
|
||
if (geoMeshes.length === 0) {
|
||
console.warn('[GdForest]', def.file, 'нет геометрии');
|
||
return null;
|
||
}
|
||
// Bake parent-chain (Y-up→Z-up + scale из glTF __root__) в вершины.
|
||
for (const m of geoMeshes) {
|
||
m.computeWorldMatrix(true);
|
||
m.bakeCurrentTransformIntoVertices();
|
||
m.parent = null;
|
||
if (m.rotationQuaternion) m.rotationQuaternion = null;
|
||
m.rotation.set(0, 0, 0);
|
||
m.scaling.set(1, 1, 1);
|
||
m.position.set(0, 0, 0);
|
||
}
|
||
// Уничтожаем вспомогательные node-ы (__root__)
|
||
for (const m of allMeshes) {
|
||
if (!geoMeshes.includes(m)) {
|
||
try { m.dispose(); } catch (e) {}
|
||
}
|
||
}
|
||
let proto;
|
||
if (geoMeshes.length === 1) {
|
||
proto = geoMeshes[0];
|
||
} else {
|
||
proto = Mesh.MergeMeshes(geoMeshes, true, true, undefined, false, true);
|
||
if (!proto) {
|
||
console.warn('[GdForest] merge failed', def.file);
|
||
return null;
|
||
}
|
||
}
|
||
proto.name = `gd_deco_proto_${id}`;
|
||
proto.isPickable = false;
|
||
// Отключить туман — иначе дальние деревья бледнеют до серого
|
||
proto.applyFog = false;
|
||
// Центрируем XZ + поднимаем низ до Y=0
|
||
proto.refreshBoundingInfo();
|
||
const bb = proto.getBoundingInfo().boundingBox;
|
||
const minY = bb.minimumWorld.y;
|
||
const cx = (bb.minimumWorld.x + bb.maximumWorld.x) * 0.5;
|
||
const cz = (bb.minimumWorld.z + bb.maximumWorld.z) * 0.5;
|
||
const sizeY = bb.maximumWorld.y - bb.minimumWorld.y;
|
||
if (Math.abs(minY) > 0.001 || Math.abs(cx) > 0.001 || Math.abs(cz) > 0.001) {
|
||
proto.position.set(-cx, -minY, -cz);
|
||
proto.bakeCurrentTransformIntoVertices();
|
||
proto.position.set(0, 0, 0);
|
||
}
|
||
// Прото отключаем от рендера (рисуются только thin-instances)
|
||
proto.isVisible = true;
|
||
// Двусторонний рендер материала + лёгкая emissive-заливка
|
||
// (как в SmoothDecoManager — иначе нижние грани чёрные)
|
||
const meshesToFix = proto.subMeshes ? [proto] : geoMeshes;
|
||
const seenMats = new Set();
|
||
for (const m of meshesToFix) {
|
||
const mat = m.material;
|
||
if (!mat || seenMats.has(mat)) continue;
|
||
seenMats.add(mat);
|
||
try {
|
||
mat.backFaceCulling = false;
|
||
if ('twoSidedLighting' in mat) mat.twoSidedLighting = true;
|
||
const base = mat.albedoColor || mat.diffuseColor;
|
||
if (base && base.scale) {
|
||
mat.emissiveColor = base.scale(0.08);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
console.log(`[GdForest] ${id} (${def.file}): sizeY=${sizeY.toFixed(2)}м, baseScale=${def.scale}`);
|
||
return { proto, baseScale: def.scale || 1.0, items: [] };
|
||
} catch (e) {
|
||
console.warn('[GdForest] load failed', def.file, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
_distributeInstances() {
|
||
for (const proto of this._protos) {
|
||
proto.items = [];
|
||
}
|
||
// FAR — только задний план. Передний план перед камерой пуст,
|
||
// чтобы не закрывать обзор игроку.
|
||
for (let i = 0; i < FAR_TOTAL; i++) {
|
||
const protoIdx = i % this._protos.length;
|
||
const proto = this._protos[protoIdx];
|
||
const xRand = Math.abs((Math.sin(i * 12.9898) * 43758)) % 1;
|
||
const zRand = Math.abs((Math.sin(i * 78.233 + 1.1) * 43758)) % 1;
|
||
const sRand = Math.abs((Math.sin(i * 41.21 + 0.5) * 43758)) % 1;
|
||
const yawRand = Math.abs((Math.sin(i * 23.45 + 2.7) * 43758)) % 1;
|
||
const x = (i / FAR_TOTAL) * this._levelWidth + (xRand - 0.5) * 12;
|
||
const z = FAR_Z_MIN + zRand * (FAR_Z_MAX - FAR_Z_MIN);
|
||
const scale = proto.baseScale * SCALE_MULT * (0.85 + sRand * 0.35);
|
||
const yaw = yawRand * Math.PI * 2;
|
||
// Узнаём высоту террейна в (x,z) raycast'ом сверху-вниз — модель
|
||
// ставится корнями на surface, а не «утопает» в склоне.
|
||
const surfaceY = this._sampleTerrainHeight(x, z);
|
||
const y = Number.isFinite(surfaceY) ? surfaceY - 0.1 : BASE_Y;
|
||
proto.items.push({ x, y, z, scale, yaw, phaseSeed: i });
|
||
}
|
||
// Заливаем матрицы каждого прото в thin-instances
|
||
for (const proto of this._protos) {
|
||
const n = proto.items.length;
|
||
const matrices = new Float32Array(n * 16);
|
||
const tmp = new Matrix();
|
||
for (let i = 0; i < n; i++) {
|
||
const it = proto.items[i];
|
||
const q = Quaternion.RotationYawPitchRoll(it.yaw, 0, 0);
|
||
const s = new Vector3(it.scale, it.scale, it.scale);
|
||
const p = new Vector3(it.x, it.y, it.z);
|
||
Matrix.ComposeToRef(s, q, p, tmp);
|
||
tmp.copyToArray(matrices, i * 16);
|
||
}
|
||
proto.proto.thinInstanceSetBuffer('matrix', matrices, 16, false);
|
||
proto.proto.thinInstanceCount = n;
|
||
proto.matrices = matrices;
|
||
}
|
||
}
|
||
|
||
/** Опрос высоты террейна в точке (x,z) через raycast сверху вниз.
|
||
* Берём только меши RobloxTerrain (по имени-префиксу). */
|
||
_sampleTerrainHeight(x, z) {
|
||
if (!this._robloxTerrain) return NaN;
|
||
const origin = new Vector3(x, 50, z);
|
||
const direction = new Vector3(0, -1, 0);
|
||
const ray = new Ray(origin, direction, 100);
|
||
const hit = this.scene.pickWithRay(ray, (mesh) => {
|
||
// меши RobloxTerrain имеют имя "__robloxMesh_..."
|
||
const n = mesh.name || '';
|
||
return n.startsWith('__robloxMesh_');
|
||
});
|
||
if (hit && hit.hit && hit.pickedPoint) return hit.pickedPoint.y;
|
||
return NaN;
|
||
}
|
||
|
||
/** Лёгкое колебание моделей от ветра — каждые 4 кадра. */
|
||
_setupWind() {
|
||
let frame = 0;
|
||
this._onBeforeRender = () => {
|
||
frame++;
|
||
if (frame % 4 !== 0) return;
|
||
this._t += 0.04;
|
||
for (const proto of this._protos) {
|
||
if (!proto.matrices) continue;
|
||
const tmp = new Matrix();
|
||
for (let i = 0; i < proto.items.length; i++) {
|
||
const it = proto.items[i];
|
||
const phase = it.phaseSeed * 0.17;
|
||
const sway = Math.sin(this._t * 0.7 + phase) * 0.05;
|
||
const q = Quaternion.RotationYawPitchRoll(it.yaw, 0, sway);
|
||
const s = new Vector3(it.scale, it.scale, it.scale);
|
||
const p = new Vector3(it.x, it.y, it.z);
|
||
Matrix.ComposeToRef(s, q, p, tmp);
|
||
tmp.copyToArray(proto.matrices, i * 16);
|
||
}
|
||
proto.proto.thinInstanceBufferUpdated('matrix');
|
||
}
|
||
};
|
||
this.scene.onBeforeRenderObservable.add(this._onBeforeRender);
|
||
}
|
||
|
||
dispose() {
|
||
if (!this.scene) return;
|
||
try {
|
||
if (this._onBeforeRender) {
|
||
this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender);
|
||
this._onBeforeRender = null;
|
||
}
|
||
} catch (e) {}
|
||
for (const proto of this._protos) {
|
||
try { proto.proto.dispose(false, true); } catch (e) {}
|
||
}
|
||
if (this._forestGround) {
|
||
try { this._forestGround.material?.dispose(true, true); } catch (e) {}
|
||
try { this._forestGround.dispose(); } catch (e) {}
|
||
this._forestGround = null;
|
||
}
|
||
if (this._robloxTerrain) {
|
||
try { this._robloxTerrain.disposeAll && this._robloxTerrain.disposeAll(); } catch (e) {}
|
||
this._robloxTerrain = null;
|
||
}
|
||
this._protos = [];
|
||
this.scene = null;
|
||
}
|
||
}
|