studio/src/editor/engine/GdGroundSkin.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

506 lines
22 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.

/**
* GdGroundSkin — 3D-травинки, 3D-цветы и неоновая полоса для GD-уровней.
*
* Этап G2 плана RUBLOX_GD_GRAPHICS_PLAN.md (v2 — настоящие 3D-меши).
*
* 1. Grass tufts — пучки травы из 2-3 пересекающихся текстурированных
* плоскостей. Через thin instances ~600 пучков вдоль трассы.
* Анимация: лёгкое колебание sin как от ветра.
* 2. Flowers — 3D-цветы (стебель + 5-лепестковая шапка) ~80 шт.
* 3. Neon edge — тонкая светящаяся полоса вдоль края пола.
*
* Использование:
* const gs = new GdGroundSkin();
* gs.attach(scene, levelWidth);
* gs.dispose();
*/
import {
MeshBuilder, StandardMaterial, Color3, Color4, Vector3, Matrix, Quaternion,
DynamicTexture, Mesh, VertexData,
} from '@babylonjs/core';
const NEON_EDGE_Y = 0.55;
const NEON_EDGE_Z = -1.51;
const GROUND_TOP_Y = 1.0; // верх grass-блока (блоки лежат от y=0 до y=1)
const TUFT_DENSITY = 1.2; // ~1.2 пучка на метр X
const FLOWER_DENSITY = 0.15;
export class GdGroundSkin {
constructor() {
this.scene = null;
this._grassTuftsProto = null; // прототип меша пучка
this._grassMatrices = null; // Float32Array с матрицами всех инстансов
this._grassCount = 0;
this._grassBaseRotations = null; // Float32Array — базовый угол колебания для каждого инстанса
this._grassBasePos = null; // [{x,y,z,scale,baseRotY}] для пересчёта матриц при ветре
this._flowersProto = null;
this._neonEdge = null;
this._neonEdgeBack = null;
this._onBeforeRender = null;
this._windT = 0;
}
attach(scene, levelWidth = 1000, shadowGenerator = null, scene3d = null) {
if (!scene) return;
this.scene = scene;
this._scene3d = scene3d;
this._createGrassTufts(levelWidth);
this._createFlowers(levelWidth);
this._createFakeShadows(levelWidth);
this._createPits(levelWidth);
this._setupWind();
}
/** Сегменты bottom-cover ПОД grass-блоками + НЕТ ничего над ямами.
* Так в ямах ничего не загораживает — видна голубое небо/skybox (бездна). */
_createPits(levelWidth) {
const floorXs = this._collectFloorXs();
if (floorXs.size === 0) return;
const minX = Math.min(...floorXs);
const maxX = Math.max(...floorXs);
// Найти непрерывные сегменты пола (где блоки ЕСТЬ)
const segments = [];
let segStart = null;
for (let x = minX; x <= maxX + 1; x++) {
if (floorXs.has(x)) {
if (segStart == null) segStart = x;
} else {
if (segStart != null) {
segments.push({ start: segStart, end: x - 1 });
segStart = null;
}
}
}
if (segments.length === 0) return;
// Тёмный материал «земля под полом»
const mat = new StandardMaterial('gd_undersole_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;
this._pitMat = mat;
this._pits = [];
const COVER_DEPTH = 200; // от y=0 далеко вниз
for (const s of segments) {
const w = s.end - s.start + 1;
const cx = (s.start + s.end) / 2;
// Сегмент = вертикальная стенка под полом этого сегмента.
// Низ — далеко вниз (закрывает низ экрана), верх — y=0 (низ grass).
const mesh = MeshBuilder.CreateBox(`gd_undersole_${s.start}_${s.end}`, {
width: w, height: COVER_DEPTH, depth: 0.2,
}, this.scene);
mesh.position.set(cx, -COVER_DEPTH / 2, -1.55);
mesh.material = mat;
mesh.isPickable = false;
mesh.applyFog = false;
this._pits.push(mesh);
}
console.log(`[GdGroundSkin] floor segments: ${segments.length}, gaps: ${segments.length - 1}`);
}
/** Синтетические тени:
* - один follow-кружок под игроком (двигается каждый кадр).
* - статические кружки под каждым шипом/cone primitive.
* Это плоские круги тёмного цвета чуть выше пола (y=1.01). */
_createFakeShadows(levelWidth) {
const dt = this._makeShadowTexture();
const mat = new StandardMaterial('gd_shadow_mat', this.scene);
mat.diffuseTexture = dt;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(0, 0, 0);
mat.useAlphaFromDiffuseTexture = true;
mat.backFaceCulling = false;
mat.specularColor = new Color3(0, 0, 0);
mat.disableLighting = true;
mat.alphaMode = 2; // BABYLON.Engine.ALPHA_COMBINE
this._shadowMat = mat;
// 1. Тень игрока — следует за ним
const playerShadow = MeshBuilder.CreateGround('gd_player_shadow', {
width: 1.4, height: 1.4,
}, this.scene);
playerShadow.position.y = 1.01;
playerShadow.material = mat;
playerShadow.isPickable = false;
playerShadow.renderingGroupId = 0;
this._playerShadow = playerShadow;
// 2. Тени под примитивами (cone/sphere/box)
const pm = this._scene3d?.primitiveManager;
const staticShadows = [];
// ID куба игрока — он двигается, тень делается через _playerShadow.
// В GD-уровнях скрипт использует REF_BODY = 'primitive:10001'.
const PLAYER_CUBE_ID = 10001;
if (pm) {
for (const data of pm.instances.values()) {
if (typeof data.x !== 'number') continue;
if (data.id === PLAYER_CUBE_ID) continue; // не дублируем тень игрока
const type = String(data.type || '');
let size = 1.2;
if (type === 'cone') size = 1.1;
else if (type === 'sphere') size = 1.0;
else if (type === 'cube') size = 1.4;
else continue;
// Пропускаем кубы лежащие на y=0 (это декоративный пол)
if (type === 'cube' && data.y === 0) continue;
const sh = MeshBuilder.CreateGround(`gd_shadow_${data.id || Math.random()}`, {
width: size, height: size,
}, this.scene);
sh.position.set(data.x, 1.01, data.z || 0);
sh.material = mat;
sh.isPickable = false;
staticShadows.push(sh);
}
}
this._staticShadows = staticShadows;
console.log(`[GdGroundSkin] fake shadows: 1 player + ${staticShadows.length} static`);
}
/** Canvas с радиальным градиентом — мягкий тёмный круг. */
_makeShadowTexture() {
const S = 128;
const dt = new DynamicTexture('gd_shadow_tex', { width: S, height: S }, this.scene, true);
const ctx = dt.getContext();
ctx.clearRect(0, 0, S, S);
const grad = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
grad.addColorStop(0.0, 'rgba(0,0,0,0.55)');
grad.addColorStop(0.6, 'rgba(0,0,0,0.25)');
grad.addColorStop(1.0, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(S / 2, S / 2, S / 2, 0, Math.PI * 2);
ctx.fill();
dt.hasAlpha = true;
dt.update();
return dt;
}
/** 3D-пучки травы вдоль уровня через thin instances. */
_createGrassTufts(levelWidth) {
const proto = this._buildTuftProto();
this._grassTuftsProto = proto;
// Собираем множество x-координат где есть пол (y=0). Трава ставится
// только там — над пропастями (дырами) её не будет.
const floorXs = this._collectFloorXs();
const hasFloorAt = (xWorld) => {
// По текущим z пол узкий (-1..1). Проверяем x округлённо.
const x = Math.round(xWorld);
return floorXs.has(x);
};
const count = Math.floor(levelWidth * TUFT_DENSITY);
const matrices = new Float32Array(count * 16);
const basePos = new Array(count);
let n = 0;
for (let i = 0; i < count; i++) {
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 / count) * levelWidth + (xRand - 0.5) * (1 / TUFT_DENSITY);
const z = (zRand - 0.5) * 2.4;
if (!hasFloorAt(x)) continue; // нет пола → пропуск (дыра/пропасть)
const scale = 0.55 + sRand * 0.6;
const baseRotY = yawRand * Math.PI * 2;
basePos[n] = { x, z, scale, baseRotY, phaseSeed: i };
n++;
}
this._grassBasePos = basePos.slice(0, n);
// re-allocate matrices под реальное n (могло быть меньше count)
this._grassMatrices = new Float32Array(n * 16);
this._grassCount = n;
this._refreshGrassMatrices(0);
proto.thinInstanceSetBuffer('matrix', this._grassMatrices, 16, false);
proto.thinInstanceCount = n;
}
/** Собрать множество x-координат, где есть блок на y=0 (пол).
* Используется и для травы, и для цветов — не сеем над пропастями. */
_collectFloorXs() {
const set = new Set();
const bm = this._scene3d?.blockManager;
if (!bm || !bm.blocks) return set;
for (const key of bm.blocks.keys()) {
const parts = String(key).split(',');
const x = parseInt(parts[0], 10);
const y = parseInt(parts[1], 10);
if (Number.isFinite(x) && y === 0) set.add(x);
}
return set;
}
/** Построить mesh-пучок — 3 пересекающиеся текстурированные плоскости. */
_buildTuftProto() {
// Текстура одного «листа» травы: тонкий вертикальный градиент с прозрачностью
const TW = 64, TH = 64;
const dt = new DynamicTexture('gd_tuft_tex', { width: TW, height: TH }, this.scene, true);
const ctx = dt.getContext();
ctx.clearRect(0, 0, TW, TH);
// Несколько «травинок» в одном тайле
const blades = [
{ x: 12, color: '#3a8a3a' },
{ x: 22, color: '#5acc5a' },
{ x: 32, color: '#88dd55' },
{ x: 42, color: '#5acc5a' },
{ x: 52, color: '#3a8a3a' },
];
for (const b of blades) {
const grad = ctx.createLinearGradient(b.x, TH, b.x, 0);
grad.addColorStop(0, b.color);
grad.addColorStop(1, '#88dd55');
ctx.strokeStyle = grad;
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(b.x, TH - 2);
ctx.quadraticCurveTo(b.x + 3, TH * 0.4, b.x + (b.x % 2 ? 4 : -4), 6);
ctx.stroke();
}
dt.hasAlpha = true;
dt.update();
const mat = new StandardMaterial('gd_tuft_mat', this.scene);
mat.diffuseTexture = dt;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(0.55, 0.55, 0.55);
mat.useAlphaFromDiffuseTexture = true;
mat.backFaceCulling = false;
mat.specularColor = new Color3(0, 0, 0);
mat.transparencyMode = 1; // ALPHATEST — нет issue с порядком сортировки
// Меш — 3 квадрата, повёрнутые на 0°, 60°, 120° вокруг Y, склеенные в один
const proto = this._makeCrossPlanes('gd_tuft_proto', 0.32, 0.35);
proto.material = mat;
proto.isPickable = false;
return proto;
}
/** Создать меш из 3-х пересекающихся плоскостей (cross-billboard). */
_makeCrossPlanes(name, halfWidth, height) {
const positions = [];
const indices = [];
const uvs = [];
const normals = [];
const blades = 3;
for (let i = 0; i < blades; i++) {
const ang = (i / blades) * Math.PI; // 0°, 60°, 120°
const cs = Math.cos(ang), sn = Math.sin(ang);
const baseIdx = positions.length / 3;
// 4 угла: bottom-left, bottom-right, top-right, top-left
const corners = [
[-halfWidth, 0, 0],
[ halfWidth, 0, 0],
[ halfWidth, height, 0],
[-halfWidth, height, 0],
];
for (const c of corners) {
const x = c[0] * cs;
const z = c[0] * sn;
positions.push(x, c[1], z);
normals.push(-sn, 0, cs);
}
uvs.push(0, 0, 1, 0, 1, 1, 0, 1);
indices.push(baseIdx, baseIdx + 1, baseIdx + 2);
indices.push(baseIdx, baseIdx + 2, baseIdx + 3);
// Обратная сторона (для двусторонних листьев)
indices.push(baseIdx, baseIdx + 2, baseIdx + 1);
indices.push(baseIdx, baseIdx + 3, baseIdx + 2);
}
const mesh = new Mesh(name, this.scene);
const vd = new VertexData();
vd.positions = positions;
vd.indices = indices;
vd.uvs = uvs;
vd.normals = normals;
vd.applyToMesh(mesh, false);
return mesh;
}
/** Пересчитать матрицы инстансов травы с учётом ветра (windT). */
_refreshGrassMatrices(windT) {
const m = this._grassMatrices;
const items = this._grassBasePos;
if (!m || !items) return;
const tmp = new Matrix();
for (let i = 0; i < items.length; i++) {
const it = items[i];
// Лёгкое колебание: tilt + yaw чуть-чуть
const phase = it.phaseSeed * 0.13;
const tilt = Math.sin(windT * 1.6 + phase) * 0.12;
const yaw = it.baseRotY + Math.sin(windT * 0.7 + phase * 1.3) * 0.03;
const q = Quaternion.RotationYawPitchRoll(yaw, 0, tilt);
const s = new Vector3(it.scale, it.scale, it.scale);
const p = new Vector3(it.x, GROUND_TOP_Y, it.z);
Matrix.ComposeToRef(s, q, p, tmp);
tmp.copyToArray(m, i * 16);
}
}
/** 3D-цветы — стебель + 5-лепестковая шляпка. */
_createFlowers(levelWidth) {
const proto = this._buildFlowerProto();
this._flowersProto = proto;
const floorXs = this._collectFloorXs();
const hasFloorAt = (xWorld) => floorXs.has(Math.round(xWorld));
const want = Math.floor(levelWidth * FLOWER_DENSITY);
const items = [];
for (let i = 0; i < want; i++) {
const xRand = Math.abs((Math.sin(i * 12.9898 + 100) * 43758)) % 1;
const zRand = Math.abs((Math.sin(i * 78.233 + 200) * 43758)) % 1;
const sRand = Math.abs((Math.sin(i * 41.21 + 300) * 43758)) % 1;
const yawRand = Math.abs((Math.sin(i * 23.45 + 400) * 43758)) % 1;
const x = (i / want) * levelWidth + (xRand - 0.5) * (1 / FLOWER_DENSITY);
const z = (zRand - 0.5) * 2.4;
if (!hasFloorAt(x)) continue;
items.push({ x, z, scale: 0.45 + sRand * 0.45, yaw: yawRand * Math.PI * 2 });
}
const matrices = new Float32Array(items.length * 16);
const tmp = new Matrix();
for (let i = 0; i < items.length; i++) {
const it = 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, GROUND_TOP_Y, it.z);
Matrix.ComposeToRef(s, q, p, tmp);
tmp.copyToArray(matrices, i * 16);
}
proto.thinInstanceSetBuffer('matrix', matrices, 16, true);
proto.thinInstanceCount = items.length;
}
_buildFlowerProto() {
// Текстура: 5-лепестковый цветок + желтый центр
const T = 64;
const dt = new DynamicTexture('gd_flower_tex', { width: T, height: T }, this.scene, true);
const ctx = dt.getContext();
ctx.clearRect(0, 0, T, T);
// Случайно выберем один из цветов — но раз нужны разные цветы, нарисуем сразу несколько
// и выберем через UV. Простой путь: один тип цветка (розовый) — позже можно расширить.
const cx = T / 2, cy = T / 2;
const petalR = 18, centerR = 7;
ctx.fillStyle = '#ff6b9b';
for (let p = 0; p < 5; p++) {
const ang = (p / 5) * Math.PI * 2 - Math.PI / 2;
const px = cx + Math.cos(ang) * 13;
const py = cy + Math.sin(ang) * 13;
ctx.beginPath();
ctx.arc(px, py, petalR / 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#ffe44a';
ctx.beginPath();
ctx.arc(cx, cy, centerR, 0, Math.PI * 2);
ctx.fill();
dt.hasAlpha = true;
dt.update();
const mat = new StandardMaterial('gd_flower_mat', this.scene);
mat.diffuseTexture = dt;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(0.7, 0.7, 0.7);
mat.useAlphaFromDiffuseTexture = true;
mat.backFaceCulling = false;
mat.specularColor = new Color3(0, 0, 0);
mat.transparencyMode = 1;
// Цветок = 2 крест-плоскости (поменьше, повыше)
const proto = this._makeCrossPlanes('gd_flower_proto', 0.25, 0.35);
proto.material = mat;
proto.isPickable = false;
return proto;
}
/** Анимация ветра + follow-тень игрока. */
_setupWind() {
let frame = 0;
this._onBeforeRender = () => {
frame++;
// Тень игрока — каждый кадр (player._pos живой, в отличие от .position)
const pp = this._scene3d?.player?._pos;
if (this._playerShadow && pp) {
this._playerShadow.position.x = pp.x;
this._playerShadow.position.z = pp.z || 0;
const h = Math.max(0, pp.y - 1);
const visScale = Math.max(0.6, 1 - h * 0.12);
this._playerShadow.scaling.x = visScale;
this._playerShadow.scaling.z = visScale;
}
// Ветер — раз в 3 кадра
if (frame % 3 !== 0) return;
this._windT += 0.05;
this._refreshGrassMatrices(this._windT);
if (this._grassTuftsProto && this._grassMatrices) {
this._grassTuftsProto.thinInstanceBufferUpdated('matrix');
}
};
this.scene.onBeforeRenderObservable.add(this._onBeforeRender);
}
/** Светящаяся зелёная полоса по краю пола (передний и задний). */
_createNeonEdge(levelWidth) {
const W = levelWidth + 40;
const H = 0.12; // тонкая
// Передний край (z = -1.51)
const front = MeshBuilder.CreateBox('gd_neon_edge_front', {
width: W,
height: H,
depth: 0.05,
}, this.scene);
front.position = new Vector3(levelWidth / 2 - 10, NEON_EDGE_Y + 0.45, NEON_EDGE_Z);
const matF = new StandardMaterial('gd_neon_edge_mat', this.scene);
matF.diffuseColor = new Color3(0.13, 1, 0.4);
matF.emissiveColor = new Color3(0.13, 1, 0.4);
matF.disableLighting = true;
front.material = matF;
front.isPickable = false;
front.applyFog = false;
this._neonEdge = front;
// Задний край (z = 1.51) — на всякий случай если камера сверху/сбоку
const back = MeshBuilder.CreateBox('gd_neon_edge_back', {
width: W,
height: H,
depth: 0.05,
}, this.scene);
back.position = new Vector3(levelWidth / 2 - 10, NEON_EDGE_Y + 0.45, 1.51);
back.material = matF;
back.isPickable = false;
back.applyFog = false;
this._neonEdgeBack = back;
}
dispose() {
if (!this.scene) return;
try {
if (this._onBeforeRender) {
this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender);
this._onBeforeRender = null;
}
} catch (e) {}
const all = [
this._grassTuftsProto, this._flowersProto, this._neonEdge, this._neonEdgeBack,
this._playerShadow, ...(this._staticShadows || []), ...(this._pits || []),
];
for (const m of all) {
if (m) {
try { m.material?.dispose(true, true); } catch (e) {}
try { m.dispose(); } catch (e) {}
}
}
this._grassTuftsProto = this._flowersProto = this._neonEdge = this._neonEdgeBack = null;
this._playerShadow = null;
this._staticShadows = null;
this._scene3d = null;
this._grassMatrices = null;
this._grassBasePos = null;
this.scene = null;
}
}