feat(studio): задача 16 — кастомное небо (SkyboxManager)
Процедурный gradient-skybox без внешних текстур: купол-сфера с ShaderMaterial (градиент верх→горизонт→низ + солнечный диск + дымка), low-poly горы на горизонте, billboard-облака с дрейфом, атмосферный туман, звёзды. Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space. Плавный fadeTo между пресетами (анимация цветов купола в tick). game-API (студия): game.scene.setSkybox/setClouds/setFog, game.scene.skybox.fadeTo/setSunDirection. Сериализация неба в project_data. Тик облаков/перехода работает и в редакторе (превью). Плеер пока НЕ портирован (по указанию — сначала проверка в студии). Тест-игра «Небесная демка» id=2541 (dev-режим is_test=true, не в ленте). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
75e83a9f3b
commit
71536668f2
@ -73,6 +73,7 @@ import { BeamManager } from './BeamManager';
|
|||||||
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
||||||
import { DynamicsManager } from './DynamicsManager';
|
import { DynamicsManager } from './DynamicsManager';
|
||||||
import { Environment } from './Environment';
|
import { Environment } from './Environment';
|
||||||
|
import { SkyboxManager } from './SkyboxManager';
|
||||||
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
||||||
import { GameAudioManager } from './GameAudioManager';
|
import { GameAudioManager } from './GameAudioManager';
|
||||||
import { AssetManager } from './AssetManager';
|
import { AssetManager } from './AssetManager';
|
||||||
@ -1292,6 +1293,7 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
this.dynamics = new DynamicsManager(this);
|
this.dynamics = new DynamicsManager(this);
|
||||||
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
||||||
|
this.skybox = new SkyboxManager(this.scene); // задача 16 — кастомное небо
|
||||||
this.audioManager = new AudioManager();
|
this.audioManager = new AudioManager();
|
||||||
this.assetManager = new AssetManager();
|
this.assetManager = new AssetManager();
|
||||||
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
||||||
@ -1440,6 +1442,10 @@ export class BabylonScene {
|
|||||||
if (this._isPlaying && this.environment) {
|
if (this._isPlaying && this.environment) {
|
||||||
this.environment.tick(dt);
|
this.environment.tick(dt);
|
||||||
}
|
}
|
||||||
|
// Небо: дрейф облаков + fadeTo — работает всегда (превью в редакторе).
|
||||||
|
if (this.skybox) {
|
||||||
|
this.skybox.tick(dt);
|
||||||
|
}
|
||||||
// Анимация жидкостей — работает всегда (и в редакторе)
|
// Анимация жидкостей — работает всегда (и в редакторе)
|
||||||
if (this.blockManager) {
|
if (this.blockManager) {
|
||||||
this.blockManager.tick(dt);
|
this.blockManager.tick(dt);
|
||||||
@ -5633,6 +5639,11 @@ export class BabylonScene {
|
|||||||
return wasActive;
|
return wasActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Небо (задача 16) — обёртки для game-API и UI редактора ──────────────
|
||||||
|
setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); }
|
||||||
|
setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); }
|
||||||
|
setSkyFog(opts) { this.skybox?.setFog(opts); if (this._onSceneChange) this._onSceneChange(); }
|
||||||
|
|
||||||
moveSelectedTo(x, y, z) {
|
moveSelectedTo(x, y, z) {
|
||||||
if (!this.selection) return;
|
if (!this.selection) return;
|
||||||
const sel = this.selection.getSelection();
|
const sel = this.selection.getSelection();
|
||||||
@ -7378,6 +7389,7 @@ export class BabylonScene {
|
|||||||
crosshair: this._crosshair || 'dot',
|
crosshair: this._crosshair || 'dot',
|
||||||
shadowQuality: this._shadowQuality || 'soft',
|
shadowQuality: this._shadowQuality || 'soft',
|
||||||
environment: this.environment ? this.environment.serialize() : null,
|
environment: this.environment ? this.environment.serialize() : null,
|
||||||
|
skybox: this.skybox ? this.skybox.serialize() : null,
|
||||||
audio: this.audioManager ? this.audioManager.serialize() : null,
|
audio: this.audioManager ? this.audioManager.serialize() : null,
|
||||||
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
||||||
assets: this.assetManager ? this.assetManager.serialize() : [],
|
assets: this.assetManager ? this.assetManager.serialize() : [],
|
||||||
@ -7856,6 +7868,10 @@ export class BabylonScene {
|
|||||||
if (state.scene.environment && this.environment) {
|
if (state.scene.environment && this.environment) {
|
||||||
this.environment.load(state.scene.environment);
|
this.environment.load(state.scene.environment);
|
||||||
}
|
}
|
||||||
|
// Кастомное небо (задача 16)
|
||||||
|
if (state.scene.skybox && this.skybox) {
|
||||||
|
this.skybox.load(state.scene.skybox);
|
||||||
|
}
|
||||||
// Аудио (фоновая музыка/амбиент)
|
// Аудио (фоновая музыка/амбиент)
|
||||||
if (state.scene.audio && this.audioManager) {
|
if (state.scene.audio && this.audioManager) {
|
||||||
this.audioManager.load(state.scene.audio);
|
this.audioManager.load(state.scene.audio);
|
||||||
|
|||||||
@ -2882,6 +2882,28 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// === Небо и атмосфера (задача 16) ===
|
||||||
|
if (cmd === 'scene.setSkybox') {
|
||||||
|
try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.setClouds') {
|
||||||
|
try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.setFog') {
|
||||||
|
try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.skyboxFadeTo') {
|
||||||
|
try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.skyboxSunDir') {
|
||||||
|
try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd === 'scene.setColor') {
|
if (cmd === 'scene.setColor') {
|
||||||
try {
|
try {
|
||||||
const color = payload?.color;
|
const color = payload?.color;
|
||||||
|
|||||||
@ -1966,6 +1966,32 @@ const game = {
|
|||||||
const bag = _dataIndex[r];
|
const bag = _dataIndex[r];
|
||||||
return bag ? bag[key] : undefined;
|
return bag ? bag[key] : undefined;
|
||||||
},
|
},
|
||||||
|
// === Небо и атмосфера (задача 16) ===
|
||||||
|
/**
|
||||||
|
* Установить небо. Либо пресет, либо ручной gradient:
|
||||||
|
* game.scene.setSkybox({ preset: 'lowpoly-roblox' });
|
||||||
|
* game.scene.setSkybox({ mode:'gradient', topColor:'#4a90e2', bottomColor:'#cfd8dc' });
|
||||||
|
* Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space.
|
||||||
|
*/
|
||||||
|
setSkybox(opts) { _send('scene.setSkybox', { opts: opts || {} }); },
|
||||||
|
/**
|
||||||
|
* Облака поверх неба:
|
||||||
|
* game.scene.setClouds({ enabled:true, cover:0.5, speed:0.02, color:'#ffffff' });
|
||||||
|
*/
|
||||||
|
setClouds(opts) { _send('scene.setClouds', { opts: opts || {} }); },
|
||||||
|
/**
|
||||||
|
* Атмосферный туман:
|
||||||
|
* game.scene.setFog({ color:'#dddddd', density:0.005 });
|
||||||
|
* game.scene.setFog({ enabled:false });
|
||||||
|
*/
|
||||||
|
setFog(opts) { _send('scene.setFog', { opts: opts || {} }); },
|
||||||
|
/** Объект управления небом: плавный переход + солнце. */
|
||||||
|
skybox: {
|
||||||
|
/** Плавный переход к пресету за N секунд: skybox.fadeTo({preset:'sunset'}, 2). */
|
||||||
|
fadeTo(opts, durationSec) { _send('scene.skyboxFadeTo', { opts: opts || {}, duration: Number(durationSec) || 2 }); },
|
||||||
|
/** Направление солнца (для анимации дуги): setSunDirection({x,y,z}). */
|
||||||
|
setSunDirection(dir) { _send('scene.skyboxSunDir', { dir: dir || {} }); },
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Теги объектов (Фаза 5.6) — как CollectionService в Roblox.
|
* Теги объектов (Фаза 5.6) — как CollectionService в Roblox.
|
||||||
* Помечаешь объекты тегом, потом находишь все объекты с тегом.
|
* Помечаешь объекты тегом, потом находишь все объекты с тегом.
|
||||||
|
|||||||
504
src/editor/engine/SkyboxManager.js
Normal file
504
src/editor/engine/SkyboxManager.js
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
},
|
||||||
|
'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 },
|
||||||
|
},
|
||||||
|
'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 },
|
||||||
|
},
|
||||||
|
'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 },
|
||||||
|
},
|
||||||
|
'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 },
|
||||||
|
},
|
||||||
|
'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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SkyboxManager {
|
||||||
|
constructor(scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Купол ──────────────────────────────────────────────────────────────
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 || {}) };
|
||||||
|
} 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 || {}) };
|
||||||
|
this._rebuildExtras();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман). */
|
||||||
|
_rebuildAll() {
|
||||||
|
this._applyShaderUniforms();
|
||||||
|
this._rebuildExtras();
|
||||||
|
}
|
||||||
|
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user