studio/src/editor/engine/GdSkybox.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
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)
2026-05-27 23:41:10 +03:00

393 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.

/**
* GdSkybox — небо, параллакс-горы/лес и туман для GD-уровней.
*
* Этап G1 плана RUBLOX_GD_GRAPHICS_PLAN.md.
*
* Применяется только если в сцене есть primitive с kind=gd_finish (= GD-уровень).
* Не трогает другие проекты.
*
* Что создаёт:
* 1. Skybox — большой куб с canvas-текстурой градиента (утро→полдень).
* 2. Дальние горы (z=80) — billboard с canvas-силуэтом гор. Двигается со скроллом ×0.2.
* 3. Лес поближе (z=40) — canvas-силуэт леса. Двигается со скроллом ×0.5.
* 4. Babylon fog — мягкое растворение дальних объектов.
*
* Использование:
* const gdSkybox = new GdSkybox();
* gdSkybox.attach(scene, sideviewCamera);
* // ... при выгрузке проекта:
* gdSkybox.dispose();
*/
import {
MeshBuilder, StandardMaterial, Texture, Color3, Color4, Vector3,
DynamicTexture,
} from '@babylonjs/core';
const SKYBOX_SIZE = 2000;
// Дальние горы — далеко по z, низ упирается в горизонт (y≈0).
const MOUNTAIN_Z = 220;
const MOUNTAIN_W = 600;
const MOUNTAIN_H = 80;
const MOUNTAIN_BOTTOM_Y = -5; // низ за полом
// Лес — поближе, но всё равно очень далеко (чтобы не перекрывать игрока).
const FOREST_Z = 120;
const FOREST_W = 400;
const FOREST_H = 35;
const FOREST_BOTTOM_Y = -5;
const MOUNTAIN_SCROLL = 0.15;
const FOREST_SCROLL = 0.35;
export class GdSkybox {
constructor() {
this.scene = null;
this.camera = null;
this._skybox = null;
this._mountains = null;
this._forest = null;
this._groundFiller = null;
this._prevFogMode = 0;
this._prevFogColor = null;
this._prevFogDensity = 0;
this._prevClearColor = null;
this._onBeforeRender = null;
}
/** Подключить к сцене. camera нужна для параллакса (читаем .position.x). */
attach(scene, camera) {
if (!scene) return;
this.scene = scene;
this.camera = camera;
// Запомним прежнее состояние, чтобы dispose откатил
this._prevClearColor = scene.clearColor?.clone?.() || new Color4(0.5, 0.7, 0.9, 1);
this._prevFogMode = scene.fogMode;
this._prevFogColor = scene.fogColor?.clone?.() || new Color3(0.55, 0.7, 0.85);
this._prevFogDensity = scene.fogDensity;
// Чтобы за skybox-ом не было голой clearColor (полосы на швах),
// ставим её под цвет неба.
scene.clearColor = new Color4(0.55, 0.78, 0.92, 1);
this._createSkybox();
// _createBottomCover убран — теперь под полом сегменты bottom-cover
// рисуются в GdGroundSkin (с дырками в местах ям, чтобы pit-mesh
// спускались до низа экрана).
this._createMountains();
this._createForest();
this._setupFog();
this._setupParallax();
}
/** Вертикальная стенка ПОД полом — закрывает низ кадра.
* Верх на y=0 (низ grass-блоков), низ далеко вниз. */
_createBottomCover() {
const cover = MeshBuilder.CreateBox('gd_bottom_cover', {
width: 4000, height: 500, depth: 0.2,
}, this.scene);
cover.position = new Vector3(0, -250, -1.55);
const mat = new StandardMaterial('gd_bottom_cover_mat', this.scene);
mat.diffuseColor = new Color3(0.30, 0.20, 0.12);
mat.emissiveColor = new Color3(0.25, 0.17, 0.10);
mat.specularColor = new Color3(0, 0, 0);
mat.disableLighting = true;
cover.material = mat;
cover.isPickable = false;
cover.applyFog = false;
this._bottomCover = cover;
}
/** Темная плоскость ВНУТРИ области пола (z=-1.45..-1.5, y=0..1) — рисует
* «глубокую пропасть» в ямах. За grass-блоками не видна (она z=-1.5+ε
* чуть перед задней стенкой пола, но СЗАДИ передней стенки видимых блоков).
* ВНИМАНИЕ: пока не использую — оставлено для следующей итерации. */
/** Плоскость-заглушка под лесом/полом — закрывает голубое «дно» skybox.
* Тёмно-зелёный/коричневый, имитирует уходящую к горизонту землю.
* ВАЖНО: ставим БЛИЖЕ камеры чем лес — иначе fog делает её бледно-серой. */
_createGroundFiller() {
const W = 16, H = 256;
const dt = new DynamicTexture('gd_groundfill_tex', { width: W, height: H }, this.scene, false);
const ctx = dt.getContext();
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0.00, '#4a6a3a'); // верх — насыщенная зелень (вровень с травой)
g.addColorStop(0.40, '#3a4a25'); // середина — землянистый
g.addColorStop(1.00, '#2a3018'); // низ — тёмная земля
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
dt.update();
const mesh = MeshBuilder.CreatePlane('gd_groundfiller', {
width: 800,
height: 220,
}, this.scene);
// Z=15 — это БЛИЖЕ камеры чем лес (FOREST_Z=120). Туман сюда почти не достаёт.
// Y=-111 → верх на y≈-1 (под полом который y=0), низ далеко внизу.
mesh.position = new Vector3(0, -111, 15);
const mat = new StandardMaterial('gd_groundfiller_mat', this.scene);
mat.diffuseTexture = dt;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(1.0, 1.0, 1.0);
mat.disableLighting = true;
mat.backFaceCulling = true;
mesh.material = mat;
// НЕ применять fog — иначе посереет
mesh.applyFog = false;
this._groundFiller = mesh;
}
/** Главный куб неба с canvas-градиентом. */
_createSkybox() {
const sky = MeshBuilder.CreateBox('gd_skybox', { size: SKYBOX_SIZE }, this.scene);
sky.infiniteDistance = true; // следует за камерой
sky.applyFog = false; // skybox не должен сам растворяться в тумане
const mat = new StandardMaterial('gd_skybox_mat', this.scene);
mat.backFaceCulling = false;
mat.disableLighting = true;
mat.diffuseColor = new Color3(0, 0, 0);
// Эмиссивная текстура с градиентом — canvas через DynamicTexture
const tex = this._makeSkyTexture();
mat.emissiveTexture = tex;
sky.material = mat;
this._skybox = sky;
}
/** Canvas-градиент неба: верх — тёплое голубое, низ — бледное. */
_makeSkyTexture() {
const W = 512, H = 512;
const dt = new DynamicTexture('gd_sky_tex', { width: W, height: H }, this.scene, false);
const ctx = dt.getContext();
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0.00, '#6db4e8'); // верх — насыщенно-голубой
g.addColorStop(0.45, '#9ed1f0'); // середина — небо
g.addColorStop(0.70, '#cfe6f4'); // горизонт — почти белый
g.addColorStop(1.00, '#e8f4fb'); // низ — пастель (под линию горизонта)
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// Мягкое солнце в правом-верхнем углу
const sunX = W * 0.78, sunY = H * 0.22, sunR = 60;
const sg = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, sunR);
sg.addColorStop(0, 'rgba(255,255,230,0.95)');
sg.addColorStop(1, 'rgba(255,255,230,0)');
ctx.fillStyle = sg;
ctx.beginPath();
ctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
ctx.fill();
dt.update();
return dt;
}
/** Дальние горы — billboard plane на горизонте. Низ упирается в землю. */
_createMountains() {
// Canvas — длинная горизонтальная полоса. Силуэт занимает только верх,
// низ canvas прозрачный (это «небо» под горами, оно совпадает с skybox).
const W = 2048, H = 512;
const dt = new DynamicTexture('gd_mountains_tex', { width: W, height: H }, this.scene, false);
const ctx = dt.getContext();
ctx.clearRect(0, 0, W, H);
// Силуэт двух слоёв: дальний (светлее) + ближний (темнее)
this._drawMountainLayer(ctx, W, H, /*baseY*/ 0.55, '#94aec8', /*peaks*/ 10, /*amp*/ 120);
this._drawMountainLayer(ctx, W, H, /*baseY*/ 0.72, '#6f88a4', /*peaks*/ 14, /*amp*/ 170);
dt.hasAlpha = true;
dt.update();
const mesh = MeshBuilder.CreatePlane('gd_mountains', {
width: MOUNTAIN_W,
height: MOUNTAIN_H,
}, this.scene);
// Поднимем так, чтобы низ planes был на MOUNTAIN_BOTTOM_Y (= за полом)
mesh.position = new Vector3(0, MOUNTAIN_BOTTOM_Y + MOUNTAIN_H / 2, MOUNTAIN_Z);
const mat = new StandardMaterial('gd_mountains_mat', this.scene);
mat.diffuseTexture = dt;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(0.85, 0.85, 0.85);
mat.disableLighting = true;
mat.useAlphaFromDiffuseTexture = true;
// Одностороннее отображение — иначе видна зеркальная копия снизу
mat.backFaceCulling = true;
mesh.material = mat;
mesh.applyFog = true;
// План смотрит лицом в z (на камеру), но MeshBuilder создаёт лицом в +z.
// Камера у нас смотрит в +z (sideview), значит лицо planes должно быть в z.
// Без поворота alpha-маска применяется корректно с обеих сторон, проверим.
this._mountains = mesh;
}
/** Лес поближе — тёмные ёлки на горизонте. */
_createForest() {
const W = 2048, H = 256;
const dt = new DynamicTexture('gd_forest_tex', { width: W, height: H }, this.scene, false);
const ctx = dt.getContext();
ctx.clearRect(0, 0, W, H);
this._drawForestLayer(ctx, W, H);
dt.hasAlpha = true;
dt.update();
const mesh = MeshBuilder.CreatePlane('gd_forest', {
width: FOREST_W,
height: FOREST_H,
}, this.scene);
mesh.position = new Vector3(0, FOREST_BOTTOM_Y + FOREST_H / 2, FOREST_Z);
const mat = new StandardMaterial('gd_forest_mat', this.scene);
mat.diffuseTexture = dt;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(0.75, 0.78, 0.75);
mat.disableLighting = true;
mat.useAlphaFromDiffuseTexture = true;
mat.backFaceCulling = true;
mesh.material = mat;
mesh.applyFog = true;
this._forest = mesh;
}
/** Слой гор: силуэт в верхней половине canvas. */
_drawMountainLayer(ctx, W, H, baseY, color, peaks, maxAmp) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, H);
const step = W / peaks;
for (let i = 0; i <= peaks; i++) {
const x = i * step;
const r1 = Math.abs((Math.sin(i * 12.9898) * 43758.5453) % 1);
const r2 = Math.abs((Math.sin(i * 78.233 + 1) * 43758.5453) % 1);
const yOffset = (r1 * 0.6 + r2 * 0.4) * maxAmp;
// под-пики (мелкие зазубрины)
const sub = 5;
const subStep = step / sub;
for (let s = 0; s < sub; s++) {
const subX = x + s * subStep - step / 2;
if (subX < 0) continue;
const subR = Math.abs((Math.sin((i * sub + s) * 41.421) * 43758) % 1);
const subY = H * baseY - yOffset + (subR - 0.5) * maxAmp * 0.4;
ctx.lineTo(subX, subY);
}
const y = H * baseY - yOffset;
ctx.lineTo(x, y);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
}
/** Ёлки — низ canvas, верх прозрачный. */
_drawForestLayer(ctx, W, H) {
const treeStep = 20;
for (let x = 0; x < W; x += treeStep) {
const r = Math.abs((Math.sin(x * 0.231) * 43758.5453) % 1);
const treeW = 18 + r * 12;
const treeH = 80 + r * 60;
const cx = x + treeW / 2;
const baseY = H - 2; // низ ствола почти у нижнего края canvas
// ствол
ctx.fillStyle = '#3a2a1a';
ctx.fillRect(cx - 2, baseY - 8, 4, 8);
// крона — 3 наслоённых треугольника
ctx.fillStyle = '#2e4e2e';
for (let i = 0; i < 3; i++) {
const lvlH = treeH * 0.4;
const lvlW = treeW * (1 - i * 0.18);
const yTop = baseY - 8 - (treeH - i * (treeH / 4));
const yBot = yTop + lvlH;
ctx.beginPath();
ctx.moveTo(cx, yTop);
ctx.lineTo(cx + lvlW / 2, yBot);
ctx.lineTo(cx - lvlW / 2, yBot);
ctx.closePath();
ctx.fill();
}
}
}
_setupFog() {
const sc = this.scene;
sc.fogMode = 2; // FOGMODE_EXP
sc.fogColor = new Color3(0.81, 0.90, 0.96); // под горизонт skybox
sc.fogDensity = 0.008;
}
/** Каждый кадр — фоны следуют за камерой по X, параллакс через uOffset текстуры. */
_setupParallax() {
// Включаем wrap для текстур чтобы uOffset закольцовывался
try {
const t1 = this._mountains?.material?.diffuseTexture;
if (t1) {
t1.wrapU = 1; // BABYLON.Texture.WRAP_ADDRESSMODE
t1.wrapV = 1;
}
const t2 = this._forest?.material?.diffuseTexture;
if (t2) {
t2.wrapU = 1;
t2.wrapV = 1;
}
} catch (e) {}
// U-coord смещение: камера движется на 1м → текстура сдвигается на
// (1 - scroll) / MOUNTAIN_W. То есть видим параллакс через смещение
// текстуры, а сама плоскость всегда перед камерой.
this._onBeforeRender = () => {
if (!this.camera || !this._mountains) return;
const camX = this.camera.position?.x || 0;
this._mountains.position.x = camX;
this._forest.position.x = camX;
if (this._groundFiller) this._groundFiller.position.x = camX;
// bottom-cover тоже едет за камерой по X (это «бесконечная» стенка)
if (this._bottomCover) this._bottomCover.position.x = camX;
// Параллакс — текстура «отстаёт» от движения камеры
const t1 = this._mountains.material?.diffuseTexture;
const t2 = this._forest.material?.diffuseTexture;
const t1e = this._mountains.material?.emissiveTexture;
const t2e = this._forest.material?.emissiveTexture;
const off1 = (camX * MOUNTAIN_SCROLL) / MOUNTAIN_W;
const off2 = (camX * FOREST_SCROLL) / FOREST_W;
if (t1) t1.uOffset = off1;
if (t1e) t1e.uOffset = off1;
if (t2) t2.uOffset = off2;
if (t2e) t2e.uOffset = off2;
};
this.scene.onBeforeRenderObservable.add(this._onBeforeRender);
}
/** Снять всё и вернуть прежние fog/clearColor. */
dispose() {
if (!this.scene) return;
try {
if (this._onBeforeRender) {
this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender);
this._onBeforeRender = null;
}
} catch (e) {}
for (const m of [this._skybox, this._mountains, this._forest, this._groundFiller, this._bottomCover]) {
if (m) {
try { m.material?.dispose(true, true); } catch (e) {}
try { m.dispose(); } catch (e) {}
}
}
this._skybox = this._mountains = this._forest = this._groundFiller = this._bottomCover = null;
try {
this.scene.clearColor = this._prevClearColor;
this.scene.fogMode = this._prevFogMode;
this.scene.fogColor = this._prevFogColor;
this.scene.fogDensity = this._prevFogDensity;
} catch (e) {}
this.scene = null;
this.camera = null;
}
}
/** Хелпер: определить, является ли сцена GD-уровнем (есть kind=gd_finish). */
export function isGdLevel(stateOrPrimitives) {
let primitives = stateOrPrimitives;
if (stateOrPrimitives?.scene?.primitives) {
primitives = stateOrPrimitives.scene.primitives;
}
if (!Array.isArray(primitives)) return false;
for (const p of primitives) {
if (p?.type === 'gd_finish') return true;
}
return false;
}