From 192e721ba29cae3fee4cf335fe740b03d6b99eba Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 09:49:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(player):=20SkyboxManager.hexToRgb=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D1=82=D0=BA=D0=B8=D0=B9=20=D1=85=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=20#fff=20(=D0=BF=D0=BE=D1=80=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/engine/SkyboxManager.js | 1135 ++++++++++++++++++----------------- 1 file changed, 570 insertions(+), 565 deletions(-) diff --git a/src/engine/SkyboxManager.js b/src/engine/SkyboxManager.js index d148cab..34b90ee 100644 --- a/src/engine/SkyboxManager.js +++ b/src/engine/SkyboxManager.js @@ -1,565 +1,570 @@ -/** - * SkyboxManager — кастомное небо для сцены (задача 16). - * - * Реализует процедурный gradient-skybox без внешних текстур (работает offline): - * - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ, - * солнечный диск, лёгкая дымка у горизонта; - * - low-poly горы на горизонте (как в Roblox-эталоне); - * - billboard-облака (плоскости, медленный дрейф); - * - атмосферный туман (scene.fog). - * - * Пресеты: clear-summer-day / cloudy / sunset / starry-night / space / - * lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними. - * - * API (через game.scene.*): - * setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... }) - * setClouds({ enabled, cover, density, speed, color }) - * setFog({ color, density, near, far } | enabled:false) - * skybox.fadeTo(opts, durationSec) - * skybox.setSunDirection({x,y,z}) - * - * Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/. - */ -import { - Color3, Color4, Vector3, - MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture, - DynamicTexture, VertexData, Mesh, -} from '@babylonjs/core'; - -// ── Шейдер градиентного неба ────────────────────────────────────────────── -// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх), -// плюс солнечный диск и осветление у горизонта (дымка). -const SKY_VERT = ` -precision highp float; -attribute vec3 position; -uniform mat4 worldViewProjection; -varying vec3 vDir; -void main(void){ - vDir = normalize(position); - gl_Position = worldViewProjection * vec4(position, 1.0); -}`; - -const SKY_FRAG = ` -precision highp float; -varying vec3 vDir; -uniform vec3 topColor; -uniform vec3 bottomColor; -uniform vec3 horizonColor; -uniform vec3 sunDir; -uniform vec3 sunColor; -uniform float sunSize; // 0..1 угловой радиус -uniform float horizonHaze; // 0..1 сила дымки у горизонта -void main(void){ - float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх - // Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5) - vec3 col; - if (h < 0.5) { - col = mix(bottomColor, horizonColor, h * 2.0); - } else { - col = mix(horizonColor, topColor, (h - 0.5) * 2.0); - } - // Дымка у горизонта — осветление узкой полосы около h=0.5 - float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze; - col = mix(col, horizonColor + vec3(0.08), haze * 0.5); - // Солнечный диск + гало - float d = distance(normalize(vDir), normalize(sunDir)); - float disk = smoothstep(sunSize, sunSize * 0.4, d); - float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35; - col += sunColor * disk; - col += sunColor * glow; - gl_FragColor = vec4(col, 1.0); -}`; - -let _shaderRegistered = false; -function registerSkyShader() { - if (_shaderRegistered) return; - Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT; - Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG; - _shaderRegistered = true; -} - -const hexToRgb = (hex) => { - if (Array.isArray(hex)) return hex; - const h = String(hex || '#ffffff').replace('#', ''); - return [ - parseInt(h.substring(0, 2), 16) / 255, - parseInt(h.substring(2, 4), 16) / 255, - parseInt(h.substring(4, 6), 16) / 255, - ]; -}; - -// ── Пресеты неба ────────────────────────────────────────────────────────── -// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца; -// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман; -// stars — звёздное небо (для ночи/космоса). -const PRESETS = { - 'clear-summer-day': { - top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7', - sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6, - mountains: false, - clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 }, - fog: { color: '#cfe2f2', density: 0.0035 }, - light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' }, - }, - 'lowpoly-roblox': { - top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', - sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85, - mountains: true, - clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 }, - fog: { color: '#e2eef7', density: 0.005 }, - light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, - }, - 'cloudy': { - top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2', - sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4, - mountains: false, - clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 }, - fog: { color: '#cfd6dd', density: 0.008 }, - light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' }, - }, - 'sunset': { - top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a', - sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0, - mountains: true, - clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 }, - fog: { color: '#f0b483', density: 0.006 }, - light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' }, - }, - 'starry-night': { - top: '#070b1f', horizon: '#1b2547', bottom: '#243056', - sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3, - mountains: true, stars: true, - clouds: { enabled: false }, - fog: { color: '#141c38', density: 0.004 }, - light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' }, - }, - 'space': { - top: '#02030a', horizon: '#06070f', bottom: '#0a0c18', - sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0, - mountains: false, stars: true, - clouds: { enabled: false }, - fog: { enabled: false }, - light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' }, - }, -}; - -export class SkyboxManager { - constructor(scene, hemiLight, sunLight) { - this.scene = scene; - this.hemiLight = hemiLight || null; // ambient - this.sunLight = sunLight || null; // directional (тени) - this._dome = null; - this._mat = null; - this._mountains = null; - this._clouds = []; // [{mesh, baseX, speed}] - this._cloudRoot = null; - this._stars = null; - this._fade = null; // активный fadeTo {from,to,t,dur} - this._state = this._defaultState(); - registerSkyShader(); - this._buildDome(); - } - - _defaultState() { - return { - mode: 'gradient', - top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', - sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8, - mountains: false, stars: false, - clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 }, - fog: { enabled: false, color: '#dde8f2', density: 0.005 }, - light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, - }; - } - - // ── Купол ────────────────────────────────────────────────────────────── - _buildDome() { - const dome = MeshBuilder.CreateSphere('kubikonSkyDome', { - diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE, - }, this.scene); - dome.isPickable = false; - dome.infiniteDistance = true; // не двигается с камерой - dome.renderingGroupId = 0; - dome.applyFog = false; - - const mat = new ShaderMaterial('kubikonSkyMat', this.scene, { - vertex: 'kubikonSky', fragment: 'kubikonSky', - }, { - attributes: ['position'], - uniforms: ['worldViewProjection', 'topColor', 'bottomColor', - 'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'], - }); - mat.backFaceCulling = false; - mat.disableDepthWrite = true; // небо всегда позади - dome.material = mat; - this._dome = dome; - this._mat = mat; - this._applyShaderUniforms(); - } - - _applyShaderUniforms() { - const s = this._state; - const m = this._mat; - if (!m) return; - m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top))); - m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom))); - m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon))); - const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45]; - m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); - m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor))); - m.setFloat('sunSize', s.sunSize || 0.03); - m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7); - } - - // ── Горы (low-poly на горизонте) ──────────────────────────────────────── - _buildMountains(colorHex) { - this._disposeMountains(); - const positions = [], indices = []; - const ringR = 420, baseY = -10, segs = 64; - // Кольцо из треугольных пиков переменной высоты — стилизованный силуэт. - let vi = 0; - for (let i = 0; i < segs; i++) { - const a0 = (i / segs) * Math.PI * 2; - const a1 = ((i + 1) / segs) * Math.PI * 2; - const am = (a0 + a1) / 2; - // Псевдослучайная высота пика (детерминированно от индекса). - const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130; - const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR; - const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR; - const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR; - positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm); - indices.push(vi, vi + 1, vi + 2); - vi += 3; - } - const vd = new VertexData(); - vd.positions = positions; vd.indices = indices; - const normals = []; - VertexData.ComputeNormals(positions, indices, normals); - vd.normals = normals; - const mesh = new Mesh('kubikonSkyMountains', this.scene); - vd.applyToMesh(mesh); - mesh.isPickable = false; - mesh.applyFog = true; // горы выцветают в туман (атмосфера) - mesh.renderingGroupId = 0; - const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene); - const c = hexToRgb(colorHex || '#8fa98a'); - mat.diffuseColor = new Color3(c[0], c[1], c[2]); - mat.specularColor = new Color3(0, 0, 0); - mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25); - mesh.material = mat; - this._mountains = mesh; - } - - _disposeMountains() { - if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; } - } - - // ── Облака (billboard-плоскости) ──────────────────────────────────────── - _buildClouds(opts) { - this._disposeClouds(); - const o = opts || {}; - if (!o.enabled) return; - const cover = o.cover != null ? o.cover : 0.4; - const count = Math.round(4 + cover * 16); // 4..20 облаков - const tex = this._makeCloudTexture(o.color || '#ffffff'); - for (let i = 0; i < count; i++) { - const w = 60 + Math.random() * 90; - const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene); - plane.billboardMode = Mesh.BILLBOARDMODE_ALL; - plane.isPickable = false; - plane.applyFog = false; - plane.renderingGroupId = 0; - const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene); - mat.diffuseTexture = tex; - mat.opacityTexture = tex; - mat.emissiveColor = new Color3(1, 1, 1); - mat.disableLighting = true; - mat.backFaceCulling = false; - plane.material = mat; - const ang = Math.random() * Math.PI * 2; - const rad = 150 + Math.random() * 200; - const x = Math.cos(ang) * rad; - const z = Math.sin(ang) * rad; - const y = 90 + Math.random() * 70; - plane.position.set(x, y, z); - this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) }); - } - } - - /** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */ - _makeCloudTexture(colorHex) { - const size = 256; - const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false); - const ctx = dt.getContext(); - ctx.clearRect(0, 0, size, size); - const c = hexToRgb(colorHex); - const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`; - // Несколько перекрывающихся мягких кругов → пухлое облако. - const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]]; - for (const [bx, by, br] of blobs) { - const g = ctx.createRadialGradient(bx, by, 0, bx, by, br); - g.addColorStop(0, `rgba(${rgb},0.9)`); - g.addColorStop(0.6, `rgba(${rgb},0.5)`); - g.addColorStop(1, `rgba(${rgb},0)`); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill(); - } - dt.hasAlpha = true; - dt.update(); - return dt; - } - - _disposeClouds() { - for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); } - this._clouds = []; - } - - // ── Звёзды (точки на куполе) ───────────────────────────────────────────── - _buildStars(enabled) { - this._disposeStars(); - if (!enabled) return; - const size = 1024; - const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false); - const ctx = dt.getContext(); - ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size); - for (let i = 0; i < 600; i++) { - const x = Math.random() * size, y = Math.random() * size; - const r = Math.random() * 1.4 + 0.3; - const a = 0.4 + Math.random() * 0.6; - ctx.fillStyle = `rgba(255,255,255,${a})`; - ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); - } - dt.hasAlpha = true; dt.update(); - const dome = MeshBuilder.CreateSphere('kubikonStarsDome', { - diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE, - }, this.scene); - dome.isPickable = false; dome.infiniteDistance = true; - dome.applyFog = false; dome.renderingGroupId = 0; - const mat = new StandardMaterial('kubikonStarsMat', this.scene); - mat.diffuseTexture = dt; mat.opacityTexture = dt; - mat.emissiveColor = new Color3(1, 1, 1); - mat.disableLighting = true; mat.backFaceCulling = false; - mat.disableDepthWrite = true; - dome.material = mat; - this._stars = dome; - } - - _disposeStars() { - if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; } - } - - // ── Туман ──────────────────────────────────────────────────────────────── - _applyFog(fog) { - if (!this.scene) return; - if (fog && fog.enabled !== false && (fog.density != null || fog.color)) { - this.scene.fogMode = 2; // EXP - const c = hexToRgb(fog.color || '#dde8f2'); - this.scene.fogColor = new Color3(c[0], c[1], c[2]); - this.scene.fogDensity = fog.density != null ? fog.density : 0.005; - } else if (fog && fog.enabled === false) { - this.scene.fogMode = 0; - } - } - - // ── Освещение (единый источник: небо управляет светом сцены) ───────────── - /** Выставить направление/яркость солнца и ambient под текущее небо. */ - _applyLighting(light, sunDir) { - if (this.sunLight && sunDir) { - // DirectionalLight.direction указывает КУДА падает свет → от солнца вниз. - const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]); - if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } - } - if (!light) return; - if (this.sunLight) { - if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity; - if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor)); - } - if (this.hemiLight) { - if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity; - if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient)); - } - } - - // ── Public API ─────────────────────────────────────────────────────────── - - /** Применить пресет или ручные опции gradient. */ - setSkybox(opts) { - if (!opts) return; - const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; - const s = this._state; - if (preset) { - s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom; - s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize; - s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars; - s.clouds = { ...(preset.clouds || { enabled: false }) }; - s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) }; - s.light = preset.light || null; - this._applyLighting(preset.light, preset.sunDir); - } else { - // Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize } - if (opts.topColor) s.top = opts.topColor; - if (opts.bottomColor) s.bottom = opts.bottomColor; - if (opts.horizonColor) s.horizon = opts.horizonColor; - if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z]; - if (opts.sunColor) s.sunColor = opts.sunColor; - if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize; - if (typeof opts.haze === 'number') s.haze = opts.haze; - if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains; - if (typeof opts.stars === 'boolean') s.stars = opts.stars; - } - this._rebuildAll(); - } - - /** Облака поверх любого режима. */ - setClouds(opts) { - if (!opts) return; - this._state.clouds = { ...this._state.clouds, ...opts }; - if (this._state.clouds.enabled == null) this._state.clouds.enabled = true; - this._buildClouds(this._state.clouds); - } - - /** Атмосферный туман. */ - setFog(opts) { - if (!opts) { return; } - this._state.fog = { ...this._state.fog, ...opts }; - if (opts.enabled == null) this._state.fog.enabled = true; - this._applyFog(this._state.fog); - } - - /** Установить направление солнца (для программной анимации). */ - setSunDirection(dir) { - if (!dir) return; - this._state.sunDir = [dir.x, dir.y, dir.z]; - this._applyShaderUniforms(); - } - - /** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */ - fadeTo(opts, durationSec = 2) { - const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; - if (!target) { this.setSkybox(opts); return; } - // Запоминаем стартовые цвета и целевые — анимируем в tick(). - this._fade = { - t: 0, dur: Math.max(0.1, durationSec), - from: { - top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon), - bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor), - sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze, - }, - to: { - top: hexToRgb(target.top), horizon: hexToRgb(target.horizon), - bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor), - sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze, - }, - target, - }; - // Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман - // целевого пресета появляются сразу, цвета купола — плавно). - const s = this._state; - s.mountains = !!target.mountains; s.stars = !!target.stars; - s.clouds = { ...(target.clouds || { enabled: false }) }; - s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) }; - s.light = target.light || null; - this._rebuildExtras(); - // Запоминаем стартовые/целевые значения света для плавной анимации. - if (target.light) { - this._fade.lightFrom = { - sunInt: this.sunLight?.intensity ?? 1, - hemiInt: this.hemiLight?.intensity ?? 0.7, - }; - this._fade.lightTo = { - sunInt: target.light.sunIntensity ?? 1, - hemiInt: target.light.hemiIntensity ?? 0.7, - sunColor: target.light.sunColor, ambient: target.light.ambient, - }; - } - } - - /** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */ - _rebuildAll() { - this._applyShaderUniforms(); - this._rebuildExtras(); - this._applyLighting(this._state.light, this._state.sunDir); - } - - _rebuildExtras() { - const s = this._state; - if (s.mountains) { - // Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный). - const mc = s.stars ? '#2a3550' : '#8fa98a'; - this._buildMountains(mc); - } else this._disposeMountains(); - this._buildStars(!!s.stars); - this._buildClouds(s.clouds); - this._applyFog(s.fog); - } - - /** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */ - tick(dt) { - // Дрейф облаков по кругу. - for (const c of this._clouds) { - c.mesh.position.x += c.speed * dt * 60; - if (c.mesh.position.x > 380) c.mesh.position.x = -380; - } - // Анимация перехода неба. - if (this._fade) { - this._fade.t += dt; - const k = Math.min(1, this._fade.t / this._fade.dur); - const f = this._fade.from, t = this._fade.to, m = this._mat; - const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]; - if (m) { - m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top))); - m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom))); - m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon))); - m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor))); - const sd = mix(f.sunDir, t.sunDir); - m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); - m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k); - m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k); - // Плавно ведём направление солнца (свет) к целевому (используем sd выше). - if (this.sunLight) { - const d = new Vector3(-sd[0], -sd[1], -sd[2]); - if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } - } - } - // Плавно ведём яркость/ambient света. - if (this._fade.lightFrom && this._fade.lightTo) { - const lf = this._fade.lightFrom, lt = this._fade.lightTo; - if (this.sunLight) { - this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k; - if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor)); - } - if (this.hemiLight) { - this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k; - if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient)); - } - } - if (k >= 1) { - // Зафиксировать целевое состояние в _state (как hex). - const tp = this._fade.target; - Object.assign(this._state, { - top: tp.top, horizon: tp.horizon, bottom: tp.bottom, - sunColor: tp.sunColor, sunDir: tp.sunDir.slice(), - sunSize: tp.sunSize, haze: tp.haze, - }); - this._fade = null; - } - } - } - - serialize() { - return { ...this._state, _active: true }; - } - - load(data) { - if (!data) return; - this._state = { ...this._defaultState(), ...data }; - this._rebuildAll(); - } - - dispose() { - this._disposeMountains(); - this._disposeClouds(); - this._disposeStars(); - if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; } - } -} +/** + * SkyboxManager — кастомное небо для сцены (задача 16). + * + * Реализует процедурный gradient-skybox без внешних текстур (работает offline): + * - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ, + * солнечный диск, лёгкая дымка у горизонта; + * - low-poly горы на горизонте (как в Roblox-эталоне); + * - billboard-облака (плоскости, медленный дрейф); + * - атмосферный туман (scene.fog). + * + * Пресеты: clear-summer-day / cloudy / sunset / starry-night / space / + * lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними. + * + * API (через game.scene.*): + * setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... }) + * setClouds({ enabled, cover, density, speed, color }) + * setFog({ color, density, near, far } | enabled:false) + * skybox.fadeTo(opts, durationSec) + * skybox.setSunDirection({x,y,z}) + * + * Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/. + */ +import { + Color3, Color4, Vector3, + MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture, + DynamicTexture, VertexData, Mesh, +} from '@babylonjs/core'; + +// ── Шейдер градиентного неба ────────────────────────────────────────────── +// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх), +// плюс солнечный диск и осветление у горизонта (дымка). +const SKY_VERT = ` +precision highp float; +attribute vec3 position; +uniform mat4 worldViewProjection; +varying vec3 vDir; +void main(void){ + vDir = normalize(position); + gl_Position = worldViewProjection * vec4(position, 1.0); +}`; + +const SKY_FRAG = ` +precision highp float; +varying vec3 vDir; +uniform vec3 topColor; +uniform vec3 bottomColor; +uniform vec3 horizonColor; +uniform vec3 sunDir; +uniform vec3 sunColor; +uniform float sunSize; // 0..1 угловой радиус +uniform float horizonHaze; // 0..1 сила дымки у горизонта +void main(void){ + float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх + // Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5) + vec3 col; + if (h < 0.5) { + col = mix(bottomColor, horizonColor, h * 2.0); + } else { + col = mix(horizonColor, topColor, (h - 0.5) * 2.0); + } + // Дымка у горизонта — осветление узкой полосы около h=0.5 + float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze; + col = mix(col, horizonColor + vec3(0.08), haze * 0.5); + // Солнечный диск + гало + float d = distance(normalize(vDir), normalize(sunDir)); + float disk = smoothstep(sunSize, sunSize * 0.4, d); + float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35; + col += sunColor * disk; + col += sunColor * glow; + gl_FragColor = vec4(col, 1.0); +}`; + +let _shaderRegistered = false; +function registerSkyShader() { + if (_shaderRegistered) return; + Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT; + Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG; + _shaderRegistered = true; +} + +const hexToRgb = (hex) => { + if (Array.isArray(hex)) return hex; + let h = String(hex || '#ffffff').replace('#', '').trim(); + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + if (h.length < 6) h = (h + 'ffffff').slice(0, 6); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return [ + (Number.isFinite(r) ? r : 255) / 255, + (Number.isFinite(g) ? g : 255) / 255, + (Number.isFinite(b) ? b : 255) / 255, + ]; +}; + +// ── Пресеты неба ────────────────────────────────────────────────────────── +// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца; +// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман; +// stars — звёздное небо (для ночи/космоса). +const PRESETS = { + 'clear-summer-day': { + top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7', + sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6, + mountains: false, + clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 }, + fog: { color: '#cfe2f2', density: 0.0035 }, + light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' }, + }, + 'lowpoly-roblox': { + top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', + sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85, + mountains: true, + clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 }, + fog: { color: '#e2eef7', density: 0.005 }, + light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, + }, + 'cloudy': { + top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2', + sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4, + mountains: false, + clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 }, + fog: { color: '#cfd6dd', density: 0.008 }, + light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' }, + }, + 'sunset': { + top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a', + sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0, + mountains: true, + clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 }, + fog: { color: '#f0b483', density: 0.006 }, + light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' }, + }, + 'starry-night': { + top: '#070b1f', horizon: '#1b2547', bottom: '#243056', + sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3, + mountains: true, stars: true, + clouds: { enabled: false }, + fog: { color: '#141c38', density: 0.004 }, + light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' }, + }, + 'space': { + top: '#02030a', horizon: '#06070f', bottom: '#0a0c18', + sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0, + mountains: false, stars: true, + clouds: { enabled: false }, + fog: { enabled: false }, + light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' }, + }, +}; + +export class SkyboxManager { + constructor(scene, hemiLight, sunLight) { + this.scene = scene; + this.hemiLight = hemiLight || null; // ambient + this.sunLight = sunLight || null; // directional (тени) + this._dome = null; + this._mat = null; + this._mountains = null; + this._clouds = []; // [{mesh, baseX, speed}] + this._cloudRoot = null; + this._stars = null; + this._fade = null; // активный fadeTo {from,to,t,dur} + this._state = this._defaultState(); + registerSkyShader(); + this._buildDome(); + } + + _defaultState() { + return { + mode: 'gradient', + top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', + sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8, + mountains: false, stars: false, + clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 }, + fog: { enabled: false, color: '#dde8f2', density: 0.005 }, + light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, + }; + } + + // ── Купол ────────────────────────────────────────────────────────────── + _buildDome() { + const dome = MeshBuilder.CreateSphere('kubikonSkyDome', { + diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE, + }, this.scene); + dome.isPickable = false; + dome.infiniteDistance = true; // не двигается с камерой + dome.renderingGroupId = 0; + dome.applyFog = false; + + const mat = new ShaderMaterial('kubikonSkyMat', this.scene, { + vertex: 'kubikonSky', fragment: 'kubikonSky', + }, { + attributes: ['position'], + uniforms: ['worldViewProjection', 'topColor', 'bottomColor', + 'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'], + }); + mat.backFaceCulling = false; + mat.disableDepthWrite = true; // небо всегда позади + dome.material = mat; + this._dome = dome; + this._mat = mat; + this._applyShaderUniforms(); + } + + _applyShaderUniforms() { + const s = this._state; + const m = this._mat; + if (!m) return; + m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top))); + m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom))); + m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon))); + const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45]; + m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); + m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor))); + m.setFloat('sunSize', s.sunSize || 0.03); + m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7); + } + + // ── Горы (low-poly на горизонте) ──────────────────────────────────────── + _buildMountains(colorHex) { + this._disposeMountains(); + const positions = [], indices = []; + const ringR = 420, baseY = -10, segs = 64; + // Кольцо из треугольных пиков переменной высоты — стилизованный силуэт. + let vi = 0; + for (let i = 0; i < segs; i++) { + const a0 = (i / segs) * Math.PI * 2; + const a1 = ((i + 1) / segs) * Math.PI * 2; + const am = (a0 + a1) / 2; + // Псевдослучайная высота пика (детерминированно от индекса). + const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130; + const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR; + const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR; + const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR; + positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm); + indices.push(vi, vi + 1, vi + 2); + vi += 3; + } + const vd = new VertexData(); + vd.positions = positions; vd.indices = indices; + const normals = []; + VertexData.ComputeNormals(positions, indices, normals); + vd.normals = normals; + const mesh = new Mesh('kubikonSkyMountains', this.scene); + vd.applyToMesh(mesh); + mesh.isPickable = false; + mesh.applyFog = true; // горы выцветают в туман (атмосфера) + mesh.renderingGroupId = 0; + const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene); + const c = hexToRgb(colorHex || '#8fa98a'); + mat.diffuseColor = new Color3(c[0], c[1], c[2]); + mat.specularColor = new Color3(0, 0, 0); + mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25); + mesh.material = mat; + this._mountains = mesh; + } + + _disposeMountains() { + if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; } + } + + // ── Облака (billboard-плоскости) ──────────────────────────────────────── + _buildClouds(opts) { + this._disposeClouds(); + const o = opts || {}; + if (!o.enabled) return; + const cover = o.cover != null ? o.cover : 0.4; + const count = Math.round(4 + cover * 16); // 4..20 облаков + const tex = this._makeCloudTexture(o.color || '#ffffff'); + for (let i = 0; i < count; i++) { + const w = 60 + Math.random() * 90; + const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene); + plane.billboardMode = Mesh.BILLBOARDMODE_ALL; + plane.isPickable = false; + plane.applyFog = false; + plane.renderingGroupId = 0; + const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene); + mat.diffuseTexture = tex; + mat.opacityTexture = tex; + mat.emissiveColor = new Color3(1, 1, 1); + mat.disableLighting = true; + mat.backFaceCulling = false; + plane.material = mat; + const ang = Math.random() * Math.PI * 2; + const rad = 150 + Math.random() * 200; + const x = Math.cos(ang) * rad; + const z = Math.sin(ang) * rad; + const y = 90 + Math.random() * 70; + plane.position.set(x, y, z); + this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) }); + } + } + + /** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */ + _makeCloudTexture(colorHex) { + const size = 256; + const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false); + const ctx = dt.getContext(); + ctx.clearRect(0, 0, size, size); + const c = hexToRgb(colorHex); + const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`; + // Несколько перекрывающихся мягких кругов → пухлое облако. + const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]]; + for (const [bx, by, br] of blobs) { + const g = ctx.createRadialGradient(bx, by, 0, bx, by, br); + g.addColorStop(0, `rgba(${rgb},0.9)`); + g.addColorStop(0.6, `rgba(${rgb},0.5)`); + g.addColorStop(1, `rgba(${rgb},0)`); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill(); + } + dt.hasAlpha = true; + dt.update(); + return dt; + } + + _disposeClouds() { + for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); } + this._clouds = []; + } + + // ── Звёзды (точки на куполе) ───────────────────────────────────────────── + _buildStars(enabled) { + this._disposeStars(); + if (!enabled) return; + const size = 1024; + const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false); + const ctx = dt.getContext(); + ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size); + for (let i = 0; i < 600; i++) { + const x = Math.random() * size, y = Math.random() * size; + const r = Math.random() * 1.4 + 0.3; + const a = 0.4 + Math.random() * 0.6; + ctx.fillStyle = `rgba(255,255,255,${a})`; + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + } + dt.hasAlpha = true; dt.update(); + const dome = MeshBuilder.CreateSphere('kubikonStarsDome', { + diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE, + }, this.scene); + dome.isPickable = false; dome.infiniteDistance = true; + dome.applyFog = false; dome.renderingGroupId = 0; + const mat = new StandardMaterial('kubikonStarsMat', this.scene); + mat.diffuseTexture = dt; mat.opacityTexture = dt; + mat.emissiveColor = new Color3(1, 1, 1); + mat.disableLighting = true; mat.backFaceCulling = false; + mat.disableDepthWrite = true; + dome.material = mat; + this._stars = dome; + } + + _disposeStars() { + if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; } + } + + // ── Туман ──────────────────────────────────────────────────────────────── + _applyFog(fog) { + if (!this.scene) return; + if (fog && fog.enabled !== false && (fog.density != null || fog.color)) { + this.scene.fogMode = 2; // EXP + const c = hexToRgb(fog.color || '#dde8f2'); + this.scene.fogColor = new Color3(c[0], c[1], c[2]); + this.scene.fogDensity = fog.density != null ? fog.density : 0.005; + } else if (fog && fog.enabled === false) { + this.scene.fogMode = 0; + } + } + + // ── Освещение (единый источник: небо управляет светом сцены) ───────────── + /** Выставить направление/яркость солнца и ambient под текущее небо. */ + _applyLighting(light, sunDir) { + if (this.sunLight && sunDir) { + // DirectionalLight.direction указывает КУДА падает свет → от солнца вниз. + const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]); + if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } + } + if (!light) return; + if (this.sunLight) { + if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity; + if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor)); + } + if (this.hemiLight) { + if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity; + if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient)); + } + } + + // ── Public API ─────────────────────────────────────────────────────────── + + /** Применить пресет или ручные опции gradient. */ + setSkybox(opts) { + if (!opts) return; + const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; + const s = this._state; + if (preset) { + s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom; + s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize; + s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars; + s.clouds = { ...(preset.clouds || { enabled: false }) }; + s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) }; + s.light = preset.light || null; + this._applyLighting(preset.light, preset.sunDir); + } else { + // Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize } + if (opts.topColor) s.top = opts.topColor; + if (opts.bottomColor) s.bottom = opts.bottomColor; + if (opts.horizonColor) s.horizon = opts.horizonColor; + if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z]; + if (opts.sunColor) s.sunColor = opts.sunColor; + if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize; + if (typeof opts.haze === 'number') s.haze = opts.haze; + if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains; + if (typeof opts.stars === 'boolean') s.stars = opts.stars; + } + this._rebuildAll(); + } + + /** Облака поверх любого режима. */ + setClouds(opts) { + if (!opts) return; + this._state.clouds = { ...this._state.clouds, ...opts }; + if (this._state.clouds.enabled == null) this._state.clouds.enabled = true; + this._buildClouds(this._state.clouds); + } + + /** Атмосферный туман. */ + setFog(opts) { + if (!opts) { return; } + this._state.fog = { ...this._state.fog, ...opts }; + if (opts.enabled == null) this._state.fog.enabled = true; + this._applyFog(this._state.fog); + } + + /** Установить направление солнца (для программной анимации). */ + setSunDirection(dir) { + if (!dir) return; + this._state.sunDir = [dir.x, dir.y, dir.z]; + this._applyShaderUniforms(); + } + + /** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */ + fadeTo(opts, durationSec = 2) { + const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; + if (!target) { this.setSkybox(opts); return; } + // Запоминаем стартовые цвета и целевые — анимируем в tick(). + this._fade = { + t: 0, dur: Math.max(0.1, durationSec), + from: { + top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon), + bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor), + sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze, + }, + to: { + top: hexToRgb(target.top), horizon: hexToRgb(target.horizon), + bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor), + sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze, + }, + target, + }; + // Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман + // целевого пресета появляются сразу, цвета купола — плавно). + const s = this._state; + s.mountains = !!target.mountains; s.stars = !!target.stars; + s.clouds = { ...(target.clouds || { enabled: false }) }; + s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) }; + s.light = target.light || null; + this._rebuildExtras(); + // Запоминаем стартовые/целевые значения света для плавной анимации. + if (target.light) { + this._fade.lightFrom = { + sunInt: this.sunLight?.intensity ?? 1, + hemiInt: this.hemiLight?.intensity ?? 0.7, + }; + this._fade.lightTo = { + sunInt: target.light.sunIntensity ?? 1, + hemiInt: target.light.hemiIntensity ?? 0.7, + sunColor: target.light.sunColor, ambient: target.light.ambient, + }; + } + } + + /** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */ + _rebuildAll() { + this._applyShaderUniforms(); + this._rebuildExtras(); + this._applyLighting(this._state.light, this._state.sunDir); + } + + _rebuildExtras() { + const s = this._state; + if (s.mountains) { + // Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный). + const mc = s.stars ? '#2a3550' : '#8fa98a'; + this._buildMountains(mc); + } else this._disposeMountains(); + this._buildStars(!!s.stars); + this._buildClouds(s.clouds); + this._applyFog(s.fog); + } + + /** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */ + tick(dt) { + // Дрейф облаков по кругу. + for (const c of this._clouds) { + c.mesh.position.x += c.speed * dt * 60; + if (c.mesh.position.x > 380) c.mesh.position.x = -380; + } + // Анимация перехода неба. + if (this._fade) { + this._fade.t += dt; + const k = Math.min(1, this._fade.t / this._fade.dur); + const f = this._fade.from, t = this._fade.to, m = this._mat; + const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]; + if (m) { + m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top))); + m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom))); + m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon))); + m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor))); + const sd = mix(f.sunDir, t.sunDir); + m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); + m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k); + m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k); + // Плавно ведём направление солнца (свет) к целевому (используем sd выше). + if (this.sunLight) { + const d = new Vector3(-sd[0], -sd[1], -sd[2]); + if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } + } + } + // Плавно ведём яркость/ambient света. + if (this._fade.lightFrom && this._fade.lightTo) { + const lf = this._fade.lightFrom, lt = this._fade.lightTo; + if (this.sunLight) { + this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k; + if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor)); + } + if (this.hemiLight) { + this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k; + if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient)); + } + } + if (k >= 1) { + // Зафиксировать целевое состояние в _state (как hex). + const tp = this._fade.target; + Object.assign(this._state, { + top: tp.top, horizon: tp.horizon, bottom: tp.bottom, + sunColor: tp.sunColor, sunDir: tp.sunDir.slice(), + sunSize: tp.sunSize, haze: tp.haze, + }); + this._fade = null; + } + } + } + + serialize() { + return { ...this._state, _active: true }; + } + + load(data) { + if (!data) return; + this._state = { ...this._defaultState(), ...data }; + this._rebuildAll(); + } + + dispose() { + this._disposeMountains(); + this._disposeClouds(); + this._disposeStars(); + if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; } + } +}