studio/src/editor/engine/GdForest.js
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия 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>
2026-05-28 05:01:13 +03:00

389 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}
}