/** * GdSkybox — небо, параллакс-горы/лес и туман для GD-уровней. * * Этап G1 плана RUBLOX_GD_GRAPHICS_PLAN.md. * * Применяется только если в сцене есть primitive с kind=gd_finish (= GD-уровень). * Не трогает другие проекты. * * Что создаёт: * 1. Skybox — большой куб с canvas-текстурой градиента (утро→полдень). * 2. Дальние горы (z=80) — billboard с canvas-силуэтом гор. Двигается со скроллом ×0.2. * 3. Лес поближе (z=40) — canvas-силуэт леса. Двигается со скроллом ×0.5. * 4. Babylon fog — мягкое растворение дальних объектов. * * Использование: * const gdSkybox = new GdSkybox(); * gdSkybox.attach(scene, sideviewCamera); * // ... при выгрузке проекта: * gdSkybox.dispose(); */ import { MeshBuilder, StandardMaterial, Texture, Color3, Color4, Vector3, DynamicTexture, } from '@babylonjs/core'; const SKYBOX_SIZE = 2000; // Дальние горы — далеко по z, низ упирается в горизонт (y≈0). const MOUNTAIN_Z = 220; const MOUNTAIN_W = 600; const MOUNTAIN_H = 80; const MOUNTAIN_BOTTOM_Y = -5; // низ за полом // Лес — поближе, но всё равно очень далеко (чтобы не перекрывать игрока). const FOREST_Z = 120; const FOREST_W = 400; const FOREST_H = 35; const FOREST_BOTTOM_Y = -5; const MOUNTAIN_SCROLL = 0.15; const FOREST_SCROLL = 0.35; export class GdSkybox { constructor() { this.scene = null; this.camera = null; this._skybox = null; this._mountains = null; this._forest = null; this._groundFiller = null; this._prevFogMode = 0; this._prevFogColor = null; this._prevFogDensity = 0; this._prevClearColor = null; this._onBeforeRender = null; } /** Подключить к сцене. camera нужна для параллакса (читаем .position.x). */ attach(scene, camera) { if (!scene) return; this.scene = scene; this.camera = camera; // Запомним прежнее состояние, чтобы dispose откатил this._prevClearColor = scene.clearColor?.clone?.() || new Color4(0.5, 0.7, 0.9, 1); this._prevFogMode = scene.fogMode; this._prevFogColor = scene.fogColor?.clone?.() || new Color3(0.55, 0.7, 0.85); this._prevFogDensity = scene.fogDensity; // Чтобы за skybox-ом не было голой clearColor (полосы на швах), // ставим её под цвет неба. scene.clearColor = new Color4(0.55, 0.78, 0.92, 1); this._createSkybox(); // _createBottomCover убран — теперь под полом сегменты bottom-cover // рисуются в GdGroundSkin (с дырками в местах ям, чтобы pit-mesh // спускались до низа экрана). this._createMountains(); this._createForest(); this._setupFog(); this._setupParallax(); } /** Вертикальная стенка ПОД полом — закрывает низ кадра. * Верх на y=0 (низ grass-блоков), низ далеко вниз. */ _createBottomCover() { const cover = MeshBuilder.CreateBox('gd_bottom_cover', { width: 4000, height: 500, depth: 0.2, }, this.scene); cover.position = new Vector3(0, -250, -1.55); const mat = new StandardMaterial('gd_bottom_cover_mat', this.scene); mat.diffuseColor = new Color3(0.30, 0.20, 0.12); mat.emissiveColor = new Color3(0.25, 0.17, 0.10); mat.specularColor = new Color3(0, 0, 0); mat.disableLighting = true; cover.material = mat; cover.isPickable = false; cover.applyFog = false; this._bottomCover = cover; } /** Темная плоскость ВНУТРИ области пола (z=-1.45..-1.5, y=0..1) — рисует * «глубокую пропасть» в ямах. За grass-блоками не видна (она z=-1.5+ε * чуть перед задней стенкой пола, но СЗАДИ передней стенки видимых блоков). * ВНИМАНИЕ: пока не использую — оставлено для следующей итерации. */ /** Плоскость-заглушка под лесом/полом — закрывает голубое «дно» skybox. * Тёмно-зелёный/коричневый, имитирует уходящую к горизонту землю. * ВАЖНО: ставим БЛИЖЕ камеры чем лес — иначе fog делает её бледно-серой. */ _createGroundFiller() { const W = 16, H = 256; const dt = new DynamicTexture('gd_groundfill_tex', { width: W, height: H }, this.scene, false); const ctx = dt.getContext(); const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0.00, '#4a6a3a'); // верх — насыщенная зелень (вровень с травой) g.addColorStop(0.40, '#3a4a25'); // середина — землянистый g.addColorStop(1.00, '#2a3018'); // низ — тёмная земля ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); dt.update(); const mesh = MeshBuilder.CreatePlane('gd_groundfiller', { width: 800, height: 220, }, this.scene); // Z=15 — это БЛИЖЕ камеры чем лес (FOREST_Z=120). Туман сюда почти не достаёт. // Y=-111 → верх на y≈-1 (под полом который y=0), низ далеко внизу. mesh.position = new Vector3(0, -111, 15); const mat = new StandardMaterial('gd_groundfiller_mat', this.scene); mat.diffuseTexture = dt; mat.emissiveTexture = dt; mat.emissiveColor = new Color3(1.0, 1.0, 1.0); mat.disableLighting = true; mat.backFaceCulling = true; mesh.material = mat; // НЕ применять fog — иначе посереет mesh.applyFog = false; this._groundFiller = mesh; } /** Главный куб неба с canvas-градиентом. */ _createSkybox() { const sky = MeshBuilder.CreateBox('gd_skybox', { size: SKYBOX_SIZE }, this.scene); sky.infiniteDistance = true; // следует за камерой sky.applyFog = false; // skybox не должен сам растворяться в тумане const mat = new StandardMaterial('gd_skybox_mat', this.scene); mat.backFaceCulling = false; mat.disableLighting = true; mat.diffuseColor = new Color3(0, 0, 0); // Эмиссивная текстура с градиентом — canvas через DynamicTexture const tex = this._makeSkyTexture(); mat.emissiveTexture = tex; sky.material = mat; this._skybox = sky; } /** Canvas-градиент неба: верх — тёплое голубое, низ — бледное. */ _makeSkyTexture() { const W = 512, H = 512; const dt = new DynamicTexture('gd_sky_tex', { width: W, height: H }, this.scene, false); const ctx = dt.getContext(); const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0.00, '#6db4e8'); // верх — насыщенно-голубой g.addColorStop(0.45, '#9ed1f0'); // середина — небо g.addColorStop(0.70, '#cfe6f4'); // горизонт — почти белый g.addColorStop(1.00, '#e8f4fb'); // низ — пастель (под линию горизонта) ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); // Мягкое солнце в правом-верхнем углу const sunX = W * 0.78, sunY = H * 0.22, sunR = 60; const sg = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, sunR); sg.addColorStop(0, 'rgba(255,255,230,0.95)'); sg.addColorStop(1, 'rgba(255,255,230,0)'); ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(sunX, sunY, sunR, 0, Math.PI * 2); ctx.fill(); dt.update(); return dt; } /** Дальние горы — billboard plane на горизонте. Низ упирается в землю. */ _createMountains() { // Canvas — длинная горизонтальная полоса. Силуэт занимает только верх, // низ canvas прозрачный (это «небо» под горами, оно совпадает с skybox). const W = 2048, H = 512; const dt = new DynamicTexture('gd_mountains_tex', { width: W, height: H }, this.scene, false); const ctx = dt.getContext(); ctx.clearRect(0, 0, W, H); // Силуэт двух слоёв: дальний (светлее) + ближний (темнее) this._drawMountainLayer(ctx, W, H, /*baseY*/ 0.55, '#94aec8', /*peaks*/ 10, /*amp*/ 120); this._drawMountainLayer(ctx, W, H, /*baseY*/ 0.72, '#6f88a4', /*peaks*/ 14, /*amp*/ 170); dt.hasAlpha = true; dt.update(); const mesh = MeshBuilder.CreatePlane('gd_mountains', { width: MOUNTAIN_W, height: MOUNTAIN_H, }, this.scene); // Поднимем так, чтобы низ planes был на MOUNTAIN_BOTTOM_Y (= за полом) mesh.position = new Vector3(0, MOUNTAIN_BOTTOM_Y + MOUNTAIN_H / 2, MOUNTAIN_Z); const mat = new StandardMaterial('gd_mountains_mat', this.scene); mat.diffuseTexture = dt; mat.diffuseTexture.hasAlpha = true; mat.emissiveTexture = dt; mat.emissiveColor = new Color3(0.85, 0.85, 0.85); mat.disableLighting = true; mat.useAlphaFromDiffuseTexture = true; // Одностороннее отображение — иначе видна зеркальная копия снизу mat.backFaceCulling = true; mesh.material = mat; mesh.applyFog = true; // План смотрит лицом в −z (на камеру), но MeshBuilder создаёт лицом в +z. // Камера у нас смотрит в +z (sideview), значит лицо planes должно быть в −z. // Без поворота alpha-маска применяется корректно с обеих сторон, проверим. this._mountains = mesh; } /** Лес поближе — тёмные ёлки на горизонте. */ _createForest() { const W = 2048, H = 256; const dt = new DynamicTexture('gd_forest_tex', { width: W, height: H }, this.scene, false); const ctx = dt.getContext(); ctx.clearRect(0, 0, W, H); this._drawForestLayer(ctx, W, H); dt.hasAlpha = true; dt.update(); const mesh = MeshBuilder.CreatePlane('gd_forest', { width: FOREST_W, height: FOREST_H, }, this.scene); mesh.position = new Vector3(0, FOREST_BOTTOM_Y + FOREST_H / 2, FOREST_Z); const mat = new StandardMaterial('gd_forest_mat', this.scene); mat.diffuseTexture = dt; mat.diffuseTexture.hasAlpha = true; mat.emissiveTexture = dt; mat.emissiveColor = new Color3(0.75, 0.78, 0.75); mat.disableLighting = true; mat.useAlphaFromDiffuseTexture = true; mat.backFaceCulling = true; mesh.material = mat; mesh.applyFog = true; this._forest = mesh; } /** Слой гор: силуэт в верхней половине canvas. */ _drawMountainLayer(ctx, W, H, baseY, color, peaks, maxAmp) { ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(0, H); const step = W / peaks; for (let i = 0; i <= peaks; i++) { const x = i * step; const r1 = Math.abs((Math.sin(i * 12.9898) * 43758.5453) % 1); const r2 = Math.abs((Math.sin(i * 78.233 + 1) * 43758.5453) % 1); const yOffset = (r1 * 0.6 + r2 * 0.4) * maxAmp; // под-пики (мелкие зазубрины) const sub = 5; const subStep = step / sub; for (let s = 0; s < sub; s++) { const subX = x + s * subStep - step / 2; if (subX < 0) continue; const subR = Math.abs((Math.sin((i * sub + s) * 41.421) * 43758) % 1); const subY = H * baseY - yOffset + (subR - 0.5) * maxAmp * 0.4; ctx.lineTo(subX, subY); } const y = H * baseY - yOffset; ctx.lineTo(x, y); } ctx.lineTo(W, H); ctx.closePath(); ctx.fill(); } /** Ёлки — низ canvas, верх прозрачный. */ _drawForestLayer(ctx, W, H) { const treeStep = 20; for (let x = 0; x < W; x += treeStep) { const r = Math.abs((Math.sin(x * 0.231) * 43758.5453) % 1); const treeW = 18 + r * 12; const treeH = 80 + r * 60; const cx = x + treeW / 2; const baseY = H - 2; // низ ствола почти у нижнего края canvas // ствол ctx.fillStyle = '#3a2a1a'; ctx.fillRect(cx - 2, baseY - 8, 4, 8); // крона — 3 наслоённых треугольника ctx.fillStyle = '#2e4e2e'; for (let i = 0; i < 3; i++) { const lvlH = treeH * 0.4; const lvlW = treeW * (1 - i * 0.18); const yTop = baseY - 8 - (treeH - i * (treeH / 4)); const yBot = yTop + lvlH; ctx.beginPath(); ctx.moveTo(cx, yTop); ctx.lineTo(cx + lvlW / 2, yBot); ctx.lineTo(cx - lvlW / 2, yBot); ctx.closePath(); ctx.fill(); } } } _setupFog() { const sc = this.scene; sc.fogMode = 2; // FOGMODE_EXP sc.fogColor = new Color3(0.81, 0.90, 0.96); // под горизонт skybox sc.fogDensity = 0.008; } /** Каждый кадр — фоны следуют за камерой по X, параллакс через uOffset текстуры. */ _setupParallax() { // Включаем wrap для текстур чтобы uOffset закольцовывался try { const t1 = this._mountains?.material?.diffuseTexture; if (t1) { t1.wrapU = 1; // BABYLON.Texture.WRAP_ADDRESSMODE t1.wrapV = 1; } const t2 = this._forest?.material?.diffuseTexture; if (t2) { t2.wrapU = 1; t2.wrapV = 1; } } catch (e) {} // U-coord смещение: камера движется на 1м → текстура сдвигается на // (1 - scroll) / MOUNTAIN_W. То есть видим параллакс через смещение // текстуры, а сама плоскость всегда перед камерой. this._onBeforeRender = () => { if (!this.camera || !this._mountains) return; const camX = this.camera.position?.x || 0; this._mountains.position.x = camX; this._forest.position.x = camX; if (this._groundFiller) this._groundFiller.position.x = camX; // bottom-cover тоже едет за камерой по X (это «бесконечная» стенка) if (this._bottomCover) this._bottomCover.position.x = camX; // Параллакс — текстура «отстаёт» от движения камеры const t1 = this._mountains.material?.diffuseTexture; const t2 = this._forest.material?.diffuseTexture; const t1e = this._mountains.material?.emissiveTexture; const t2e = this._forest.material?.emissiveTexture; const off1 = (camX * MOUNTAIN_SCROLL) / MOUNTAIN_W; const off2 = (camX * FOREST_SCROLL) / FOREST_W; if (t1) t1.uOffset = off1; if (t1e) t1e.uOffset = off1; if (t2) t2.uOffset = off2; if (t2e) t2e.uOffset = off2; }; this.scene.onBeforeRenderObservable.add(this._onBeforeRender); } /** Снять всё и вернуть прежние fog/clearColor. */ dispose() { if (!this.scene) return; try { if (this._onBeforeRender) { this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender); this._onBeforeRender = null; } } catch (e) {} for (const m of [this._skybox, this._mountains, this._forest, this._groundFiller, this._bottomCover]) { if (m) { try { m.material?.dispose(true, true); } catch (e) {} try { m.dispose(); } catch (e) {} } } this._skybox = this._mountains = this._forest = this._groundFiller = this._bottomCover = null; try { this.scene.clearColor = this._prevClearColor; this.scene.fogMode = this._prevFogMode; this.scene.fogColor = this._prevFogColor; this.scene.fogDensity = this._prevFogDensity; } catch (e) {} this.scene = null; this.camera = null; } } /** Хелпер: определить, является ли сцена GD-уровнем (есть kind=gd_finish). */ export function isGdLevel(stateOrPrimitives) { let primitives = stateOrPrimitives; if (stateOrPrimitives?.scene?.primitives) { primitives = stateOrPrimitives.scene.primitives; } if (!Array.isArray(primitives)) return false; for (const p of primitives) { if (p?.type === 'gd_finish') return true; } return false; }