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)
393 lines
18 KiB
JavaScript
393 lines
18 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|