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