edb4c211a0
- Add js/sidebar.js: generates full sidebar HTML into #app-sidebar, handles role-based visibility, active link (with prefix matching), toggle wiring, collapsed state, board/features/notif init - Replace <aside class="sidebar">...</aside> with <aside id="app-sidebar"> across all 35 standard-layout pages via scripts/apply-sidebar.js - Add notifications.js to 5 pages that were missing it - Fix api.js initPage(): skip toggle re-wiring if data-sb-wired set, fix active link selector .sb-item → .sb-link - Remove stale sbl-*/nav-admin/btn-upload-nav getElementById calls that crashed after sidebar replacement (lab, classes, collection, crossword, hangman, knowledge-map, library, pet, profile) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
887 lines
42 KiB
HTML
887 lines
42 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Биомы — Красная книга РБ</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;900&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||
<link rel="stylesheet" href="/css/ls.css"/>
|
||
<style>
|
||
:root {
|
||
--rb-bg: #0a1a0d; --rb-surface: #111d13; --rb-border: #1e3523;
|
||
--rb-accent: #4ade80; --rb-text: #e2f5e8; --rb-muted: #6b9a74;
|
||
}
|
||
body { background: var(--rb-bg); color: var(--rb-text); font-family: 'Manrope', sans-serif; margin: 0; }
|
||
.app-layout { background: var(--rb-bg); }
|
||
.sb-content { padding: 0; overflow: hidden; }
|
||
/* ── RB Sidebar ─────────────────────────────────────────────────────── */
|
||
.app-layout > .sidebar { background: var(--rb-surface) !important; border-right: 1px solid var(--rb-border) !important; }
|
||
.sb-brand { border-bottom: 1px solid var(--rb-border); padding: 16px 12px 12px; }
|
||
.sb-link, a.sb-link, button.sb-link { color: var(--rb-muted) !important; }
|
||
.sb-link:hover { background: rgba(74,222,128,.08) !important; color: var(--rb-text) !important; }
|
||
.sb-link.active { background: rgba(74,222,128,.14) !important; color: var(--rb-accent) !important; font-weight: 700; }
|
||
.sb-link.active::before { background: var(--rb-accent) !important; }
|
||
.sb-toggle { color: var(--rb-muted) !important; border-color: var(--rb-border) !important; background: transparent !important; }
|
||
.sb-toggle:hover { background: rgba(74,222,128,.12) !important; color: var(--rb-accent) !important; border-color: var(--rb-accent) !important; }
|
||
.nav-user-chip { border-color: var(--rb-border) !important; }
|
||
.nav-user-chip:hover { background: rgba(74,222,128,.08) !important; }
|
||
.nav-avatar { background: linear-gradient(135deg, #166534, #15803d) !important; }
|
||
.nav-user-name { color: var(--rb-muted) !important; }
|
||
.rb-brand-link { display: flex; align-items: center; gap: 9px; text-decoration: none; flex: 1; min-width: 0; overflow: hidden; }
|
||
.rb-brand-icon { font-size: 24px; line-height: 1; flex-shrink: 0; }
|
||
.rb-brand-text { font-family: 'Unbounded', sans-serif; font-size: 10px; font-weight: 700; color: var(--rb-accent); line-height: 1.4; white-space: nowrap; }
|
||
.rb-sb-section { font-size: 9px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; color: var(--rb-muted); padding: 14px 12px 4px; margin: 0; opacity: 0.55; }
|
||
.app-layout.sb-collapsed .rb-sb-section { display: none; }
|
||
.rb-sb-divider { border: none; border-top: 1px solid var(--rb-border); margin: 8px 4px; }
|
||
.rb-back-link { opacity: 0.65; font-size: 0.82rem !important; }
|
||
.rb-back-link:hover { opacity: 1 !important; }
|
||
|
||
.biome-main { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||
|
||
/* Topbar */
|
||
.bio-topbar {
|
||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||
padding: 12px 20px; border-bottom: 1px solid var(--rb-border);
|
||
background: var(--rb-surface); flex-shrink: 0; z-index: 10;
|
||
}
|
||
.bio-topbar h1 { font-family: 'Unbounded', sans-serif; font-size: 15px; font-weight: 700; margin: 0; }
|
||
.biome-tabs { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; }
|
||
.biome-tab {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 7px 14px; border-radius: 10px; cursor: pointer;
|
||
font-size: 12px; font-weight: 600; border: 1px solid var(--rb-border);
|
||
color: var(--rb-muted); transition: all .2s;
|
||
}
|
||
.biome-tab:hover { border-color: var(--rb-accent); color: var(--rb-text); }
|
||
.biome-tab.active { border-color: var(--rb-accent); background: rgba(74,222,128,.12); color: var(--rb-accent); }
|
||
.biome-tab .tab-icon { font-size: 16px; }
|
||
|
||
/* Canvas + overlay */
|
||
.bio-body { flex: 1; position: relative; overflow: hidden; }
|
||
#bio-canvas { width: 100%; height: 100%; display: block; }
|
||
|
||
/* Species orbs HUD */
|
||
.bio-orbs {
|
||
position: absolute; right: 0; top: 0; bottom: 0; width: 280px;
|
||
background: linear-gradient(to left, rgba(10,26,13,.95), transparent);
|
||
display: flex; flex-direction: column; justify-content: center;
|
||
padding: 20px 20px 20px 40px; gap: 10px; overflow-y: auto;
|
||
pointer-events: none;
|
||
}
|
||
.bio-orbs.visible { pointer-events: auto; }
|
||
.orb-card {
|
||
background: rgba(17,29,19,.9); border: 1px solid var(--rb-border);
|
||
border-radius: 12px; padding: 12px; cursor: pointer;
|
||
transition: all .2s; display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.orb-card:hover { border-color: var(--rb-accent); transform: translateX(-4px); }
|
||
.orb-icon { font-size: 24px; flex-shrink: 0; }
|
||
.orb-info { flex: 1; min-width: 0; }
|
||
.orb-name { font-size: 12px; font-weight: 700; color: #fff; }
|
||
.orb-lat { font-size: 10px; color: var(--rb-muted); font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.orb-cat { font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 5px; }
|
||
.cat-CR { background: rgba(239,68,68,.2); color: #ef4444; }
|
||
.cat-EN { background: rgba(249,115,22,.2); color: #f97316; }
|
||
.cat-VU { background: rgba(234,179,8,.2); color: #eab308; }
|
||
.cat-NT { background: rgba(34,197,94,.2); color: #22c55e; }
|
||
.cat-LC { background: rgba(59,130,246,.2); color: #3b82f6; }
|
||
|
||
/* Biome description overlay */
|
||
.bio-info-overlay {
|
||
position: absolute; left: 20px; bottom: 24px;
|
||
background: rgba(17,29,19,.92); border: 1px solid var(--rb-border);
|
||
border-radius: 14px; padding: 16px 20px; max-width: 360px;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.bio-info-overlay h3 { font-family: 'Unbounded', sans-serif; font-size: 16px; margin: 0 0 6px; color: var(--rb-accent); }
|
||
.bio-info-overlay p { font-size: 12px; color: var(--rb-muted); margin: 0 0 10px; line-height: 1.6; }
|
||
.bio-species-count { font-size: 12px; font-weight: 700; color: var(--rb-text); }
|
||
|
||
/* Species popup */
|
||
.bio-popup {
|
||
position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
|
||
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
border-radius: 16px; padding: 24px; max-width: 320px; width: 90%;
|
||
z-index: 20; display: none; animation: popIn .2s both;
|
||
}
|
||
@keyframes popIn { from { opacity:0; transform:translate(-50%,-50%) scale(.9); } to { opacity:1; transform:translate(-50%,-50%) scale(1); } }
|
||
.bio-popup.open { display: block; }
|
||
.popup-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
|
||
.popup-icon { font-size: 40px; }
|
||
.popup-name { font-weight: 800; font-size: 15px; color: #fff; }
|
||
.popup-lat { font-style: italic; font-size: 11px; color: var(--rb-muted); }
|
||
.popup-desc { font-size: 12px; color: #c8e6ce; line-height: 1.7; margin-bottom: 14px; }
|
||
.popup-fact { background: rgba(74,222,128,.08); border-left: 3px solid var(--rb-accent); padding: 10px 12px; border-radius: 0 8px 8px 0; font-size: 12px; color: var(--rb-accent); margin-bottom: 14px; }
|
||
.popup-btns { display: flex; gap: 8px; }
|
||
.popup-btn-primary { flex: 1; background: var(--rb-accent); color: #0a1a0d; font-weight: 700; font-size: 12px; padding: 9px; border: none; border-radius: 8px; cursor: pointer; }
|
||
.popup-btn-close { background: transparent; border: 1px solid var(--rb-border); color: var(--rb-muted); font-size: 12px; padding: 9px 14px; border-radius: 8px; cursor: pointer; }
|
||
|
||
/* ── Mobile ── */
|
||
@media (max-width: 768px) {
|
||
.sb-content { overflow: auto; }
|
||
.bio-info-overlay { max-width: calc(100vw - 80px); left: 10px; bottom: 10px; padding: 12px 14px; }
|
||
.bio-info-overlay h3 { font-size: 14px; }
|
||
.bio-popup { max-width: 90vw; padding: 18px; }
|
||
.bio-scene-wrap { min-height: 45vw; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.bio-info-overlay { bottom: 8px; left: 8px; }
|
||
.popup-icon { font-size: 30px; }
|
||
.popup-btns { flex-direction: column; }
|
||
.popup-btn-primary, .popup-btn-close { width: 100%; text-align: center; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-layout" id="app">
|
||
<!-- Red Book sidebar -->
|
||
<nav class="sidebar">
|
||
<div class="sb-brand">
|
||
<a href="/red-book.html" class="rb-brand-link">
|
||
<span class="rb-brand-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></span>
|
||
<span class="sb-lbl rb-brand-text">Красная<br>книга РБ</span>
|
||
</a>
|
||
<button class="sb-toggle" onclick="toggleSidebar()"><i data-lucide="panel-left-close"></i></button>
|
||
</div>
|
||
<nav class="sb-nav">
|
||
<p class="rb-sb-section">РАЗДЕЛЫ</p>
|
||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
|
||
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
|
||
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
|
||
<a href="/red-book-biomes.html" class="sb-link active"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
|
||
<a href="/red-book-games.html" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Игры</span></a>
|
||
<hr class="rb-sb-divider">
|
||
<a href="/dashboard.html" class="sb-link rb-back-link"><i data-lucide="chevron-left" class="sb-icon"></i><span class="sb-lbl">Назад</span></a>
|
||
<a href="/profile.html" class="sb-link"><i data-lucide="user" class="sb-icon"></i><span class="sb-lbl">Профиль</span></a>
|
||
</nav>
|
||
<div class="sb-footer">
|
||
<div class="nav-user-chip" onclick="location.href='/profile.html'">
|
||
<div class="nav-avatar" id="nav-avatar">LS</div>
|
||
<span class="nav-user-name" id="nav-user">—</span>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="sb-content">
|
||
<div class="biome-main">
|
||
<div class="bio-topbar">
|
||
<a href="/red-book.html" style="color:var(--rb-muted);text-decoration:none;font-size:12px;border:1px solid var(--rb-border);padding:6px 12px;border-radius:8px;"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
|
||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg> Биомы Беларуси</h1>
|
||
<div class="biome-tabs" id="biome-tabs"></div>
|
||
<button id="sound-btn" onclick="toggleSound()" title="Звуки биома" style="display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> Звук</button>
|
||
<button id="daynight-btn" onclick="toggleDayNight()" title="День/Ночь" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>️ День</button>
|
||
<button id="weather-btn" onclick="toggleRain()" title="Дождь" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><path d="M4 14.9A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.24"/><path d="M8 19v1M8 14v1M16 19v1M16 14v1M12 21v1M12 16v1"/></svg> Дождь</button>
|
||
</div>
|
||
|
||
<div class="bio-body">
|
||
<canvas id="bio-canvas"></canvas>
|
||
|
||
<!-- Species list sidebar -->
|
||
<div class="bio-orbs visible" id="bio-orbs"></div>
|
||
|
||
<!-- Biome info -->
|
||
<div class="bio-info-overlay" id="bio-info">
|
||
<h3 id="bio-name">Загрузка...</h3>
|
||
<p id="bio-desc">Выберите биом выше для исследования.</p>
|
||
<div class="bio-species-count" id="bio-species-count"></div>
|
||
</div>
|
||
|
||
<!-- Species popup -->
|
||
<div class="bio-popup" id="bio-popup">
|
||
<div class="popup-header">
|
||
<span class="popup-icon" id="pp-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></span>
|
||
<div>
|
||
<div class="popup-name" id="pp-name">—</div>
|
||
<div class="popup-lat" id="pp-lat"></div>
|
||
<span class="orb-cat" id="pp-cat"></span>
|
||
</div>
|
||
</div>
|
||
<div class="popup-fact" id="pp-fact"></div>
|
||
<div class="popup-desc" id="pp-desc"></div>
|
||
<div class="popup-btns">
|
||
<button class="popup-btn-primary" id="pp-collect" onclick="collectFromPopup()"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg> Открыть</button>
|
||
<button class="popup-btn-close" onclick="closePopup()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js"></script>
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script>
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Biome definitions
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
const BIOMES = [
|
||
{ id: 1, name: 'Широколиственный лес', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg>', type: 'forest',
|
||
skyColor: 0x071a09, fogColor: 0x0d2e10, fogDensity: 0.025,
|
||
groundColor: 0x071209, ambientColor: 0x1a4a20, sunColor: 0x88ffaa },
|
||
{ id: 2, name: 'Хвойный лес', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg>', type: 'conifer',
|
||
skyColor: 0x060f0a, fogColor: 0x0a1a0d, fogDensity: 0.03,
|
||
groundColor: 0x050d07, ambientColor: 0x0d3015, sunColor: 0x66cc88 },
|
||
{ id: 3, name: 'Болото', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>', type: 'wetland',
|
||
skyColor: 0x050e10, fogColor: 0x0a1e22, fogDensity: 0.04,
|
||
groundColor: 0x071510, ambientColor: 0x103020, sunColor: 0x44aacc },
|
||
{ id: 4, name: 'Река и озеро', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></svg>', type: 'river',
|
||
skyColor: 0x060d14, fogColor: 0x0a1a2e, fogDensity: 0.02,
|
||
groundColor: 0x050d1a, ambientColor: 0x0a2040, sunColor: 0x4488ff },
|
||
{ id: 5, name: 'Луг и поле', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M2 22 16 8"/><path d="M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94z"/><path d="M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94z"/><path d="M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94z"/><path d="M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4z"/></svg>', type: 'meadow',
|
||
skyColor: 0x0a100a, fogColor: 0x101a10, fogDensity: 0.015,
|
||
groundColor: 0x071407, ambientColor: 0x1a3010, sunColor: 0xaadd44 },
|
||
];
|
||
|
||
let habitats = [];
|
||
let currentBiomeIdx = 0;
|
||
let speciesList = [];
|
||
let currentSpecies = null;
|
||
let orbMeshes = [];
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Three.js
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
const canvas = document.getElementById('bio-canvas');
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||
renderer.setClearColor(0x071209);
|
||
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 300);
|
||
camera.position.set(0, 3, 22);
|
||
camera.lookAt(0, 1, 0);
|
||
|
||
function resize() {
|
||
const w = canvas.parentElement.clientWidth, h = canvas.parentElement.clientHeight;
|
||
renderer.setSize(w, h, false);
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
resize();
|
||
|
||
const ambient = new THREE.AmbientLight(0x1a4a20, 1.5);
|
||
scene.add(ambient);
|
||
const sun = new THREE.DirectionalLight(0x88ffaa, 1.2);
|
||
sun.position.set(8, 12, 5);
|
||
scene.add(sun);
|
||
|
||
// Ground
|
||
const groundMat = new THREE.MeshLambertMaterial({ color: 0x071209 });
|
||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), groundMat);
|
||
ground.rotation.x = -Math.PI / 2;
|
||
scene.add(ground);
|
||
|
||
// Particles (fireflies/spores)
|
||
const PC = 400;
|
||
const pPos = new Float32Array(PC * 3);
|
||
const pPhase = new Float32Array(PC);
|
||
for (let i = 0; i < PC; i++) {
|
||
pPos[i*3] = (Math.random() - 0.5) * 40;
|
||
pPos[i*3+1] = Math.random() * 8;
|
||
pPos[i*3+2] = (Math.random() - 0.5) * 30 - 5;
|
||
pPhase[i] = Math.random() * Math.PI * 2;
|
||
}
|
||
const pgeo = new THREE.BufferGeometry();
|
||
pgeo.setAttribute('position', new THREE.BufferAttribute(pPos.slice(), 3));
|
||
const pmat = new THREE.PointsMaterial({ color: 0x88ffaa, size: 0.1, transparent: true, opacity: 0.5 });
|
||
const particles = new THREE.Points(pgeo, pmat);
|
||
scene.add(particles);
|
||
|
||
// Trees group (replaced per biome)
|
||
let treesGroup = new THREE.Group();
|
||
scene.add(treesGroup);
|
||
|
||
// Species orb group
|
||
let orbGroup = new THREE.Group();
|
||
scene.add(orbGroup);
|
||
|
||
/* Build biome scene */
|
||
function buildBiomeScene(biome) {
|
||
renderer.setClearColor(biome.skyColor);
|
||
scene.fog = new THREE.FogExp2(biome.fogColor, biome.fogDensity);
|
||
groundMat.color.setHex(biome.groundColor);
|
||
ambient.color.setHex(biome.ambientColor);
|
||
sun.color.setHex(biome.sunColor);
|
||
|
||
// Remove old trees
|
||
while (treesGroup.children.length) treesGroup.remove(treesGroup.children[0]);
|
||
|
||
if (biome.type === 'forest' || biome.type === 'conifer') buildForest(biome);
|
||
else if (biome.type === 'wetland') buildWetland(biome);
|
||
else if (biome.type === 'river') buildRiver(biome);
|
||
else if (biome.type === 'meadow') buildMeadow(biome);
|
||
|
||
// Particle color
|
||
const pColors = { forest:'#88ffaa', conifer:'#44cc77', wetland:'#aaddcc', river:'#4488ff', meadow:'#aadd55' };
|
||
pmat.color.setStyle(pColors[biome.type] || '#88ffaa');
|
||
}
|
||
|
||
function buildForest(biome) {
|
||
const isConifer = biome.type === 'conifer';
|
||
const treeColor = isConifer ? 0x0d2e12 : 0x1a4a1a;
|
||
const trunkCol = isConifer ? 0x3b1e08 : 0x5c3010;
|
||
for (let i = 0; i < 60; i++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const r = 4 + Math.random() * 20;
|
||
const sc = 0.5 + Math.random() * 1.5;
|
||
const g = new THREE.Group();
|
||
const th = (0.8 + Math.random() * 0.5) * sc;
|
||
const trunk = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.08*sc, 0.12*sc, th, 6),
|
||
new THREE.MeshLambertMaterial({ color: trunkCol })
|
||
);
|
||
trunk.position.y = th/2;
|
||
g.add(trunk);
|
||
const levels = isConifer ? 4 : 2;
|
||
for (let l = 0; l < levels; l++) {
|
||
const cr = isConifer ? (0.5 - l*0.1)*sc : (0.6 - l*0.15)*sc;
|
||
const ch = (0.6 - l*0.05)*sc;
|
||
const geo = isConifer ? new THREE.ConeGeometry(cr, ch, 7) : new THREE.SphereGeometry(cr, 8, 6);
|
||
const col = new THREE.Color(treeColor).offsetHSL(0, 0, l * 0.04);
|
||
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color: col }));
|
||
mesh.position.y = th + (isConifer ? ch*0.4 + l*ch*0.5 : cr*0.7 + l*cr*0.3);
|
||
g.add(mesh);
|
||
}
|
||
g.position.set(Math.cos(angle)*r, 0, Math.sin(angle)*r - 3);
|
||
treesGroup.add(g);
|
||
}
|
||
}
|
||
|
||
function buildWetland(biome) {
|
||
groundMat.color.setHex(0x061410);
|
||
// Reeds
|
||
for (let i = 0; i < 80; i++) {
|
||
const x = (Math.random()-0.5)*30, z = Math.random()*15-12;
|
||
const h = 1 + Math.random()*2;
|
||
const reed = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.04, 0.04, h, 4),
|
||
new THREE.MeshLambertMaterial({ color: 0x5a7a2a })
|
||
);
|
||
reed.position.set(x, h/2, z);
|
||
reed.rotation.z = (Math.random()-0.5)*0.2;
|
||
treesGroup.add(reed);
|
||
}
|
||
// Water plane
|
||
const water = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(50, 15),
|
||
new THREE.MeshLambertMaterial({ color: 0x0d2e3a, transparent: true, opacity: 0.7 })
|
||
);
|
||
water.rotation.x = -Math.PI/2;
|
||
water.position.set(0, 0.01, 0);
|
||
treesGroup.add(water);
|
||
}
|
||
|
||
function buildRiver(biome) {
|
||
// Water
|
||
const water = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(8, 50),
|
||
new THREE.MeshLambertMaterial({ color: 0x0a2040, transparent: true, opacity: 0.8 })
|
||
);
|
||
water.rotation.x = -Math.PI/2;
|
||
water.position.set(0, 0.01, 0);
|
||
treesGroup.add(water);
|
||
// Banks with trees
|
||
for (let i = 0; i < 30; i++) {
|
||
const side = Math.random() > 0.5 ? 1 : -1;
|
||
const x = side * (5 + Math.random() * 10);
|
||
const z = (Math.random()-0.5)*30;
|
||
const sc = 0.5 + Math.random();
|
||
const g = new THREE.Group();
|
||
const h = 1.2*sc;
|
||
g.add(new THREE.Mesh(new THREE.CylinderGeometry(0.1*sc, 0.15*sc, h, 6),
|
||
new THREE.MeshLambertMaterial({ color: 0x4a2a10 })));
|
||
const crown = new THREE.Mesh(new THREE.SphereGeometry(0.7*sc, 8, 6),
|
||
new THREE.MeshLambertMaterial({ color: 0x1a5520 }));
|
||
crown.position.y = h + 0.5*sc;
|
||
g.children[0].position.y = h/2;
|
||
g.add(crown);
|
||
g.position.set(x, 0, z);
|
||
treesGroup.add(g);
|
||
}
|
||
}
|
||
|
||
function buildMeadow(biome) {
|
||
groundMat.color.setHex(0x0d1f08);
|
||
// Grass tufts
|
||
for (let i = 0; i < 120; i++) {
|
||
const x = (Math.random()-0.5)*40, z = (Math.random()-0.5)*30;
|
||
const h = 0.2 + Math.random()*0.5;
|
||
const blades = 4;
|
||
for (let b = 0; b < blades; b++) {
|
||
const blade = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.01, 0.02, h, 3),
|
||
new THREE.MeshLambertMaterial({ color: 0x2a5a10 })
|
||
);
|
||
blade.position.set(x + (Math.random()-0.5)*0.3, h/2, z + (Math.random()-0.5)*0.3);
|
||
blade.rotation.z = (Math.random()-0.5)*0.5;
|
||
treesGroup.add(blade);
|
||
}
|
||
}
|
||
// Flowers
|
||
for (let i = 0; i < 30; i++) {
|
||
const x = (Math.random()-0.5)*30, z = (Math.random()-0.5)*20;
|
||
const colors = [0xff4488, 0xffcc22, 0xaa44ff, 0xff6644];
|
||
const flower = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.12, 6, 6),
|
||
new THREE.MeshLambertMaterial({ color: colors[Math.floor(Math.random()*colors.length)] })
|
||
);
|
||
flower.position.set(x, 0.5+Math.random()*0.3, z);
|
||
treesGroup.add(flower);
|
||
}
|
||
}
|
||
|
||
/* Species orbs in 3D */
|
||
function buildSpeciesOrbs(species) {
|
||
while (orbGroup.children.length) orbGroup.remove(orbGroup.children[0]);
|
||
orbMeshes = [];
|
||
|
||
const CAT_COL = { CR: 0xef4444, EN: 0xf97316, VU: 0xeab308, NT: 0x22c55e, LC: 0x3b82f6 };
|
||
|
||
species.forEach((sp, i) => {
|
||
const angle = (i / species.length) * Math.PI * 2;
|
||
const r = 5 + Math.random() * 4;
|
||
const h = 1 + Math.random() * 5;
|
||
const color = CAT_COL[sp.category] || 0x22c55e;
|
||
|
||
const orb = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.35, 12, 12),
|
||
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5, roughness: 0.3, metalness: 0.1 })
|
||
);
|
||
const cx = Math.cos(angle) * r;
|
||
const cz = Math.sin(angle) * r - 2;
|
||
orb.position.set(cx, h, cz);
|
||
orb.userData = {
|
||
sp, idx: i, phase: Math.random() * Math.PI * 2,
|
||
// Gentle orbit path
|
||
orbit: { cx, cz, r: 0.8 + Math.random() * 0.5, t: Math.random() * Math.PI * 2 },
|
||
};
|
||
orbGroup.add(orb);
|
||
orbMeshes.push(orb);
|
||
});
|
||
}
|
||
|
||
/* Raycasting for orb click */
|
||
const ray2d = new THREE.Vector2();
|
||
const rayc = new THREE.Raycaster();
|
||
canvas.addEventListener('click', e => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
ray2d.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||
ray2d.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||
rayc.setFromCamera(ray2d, camera);
|
||
const hits = rayc.intersectObjects(orbMeshes);
|
||
if (hits.length) openSpeciesPopup(hits[0].object.userData.sp);
|
||
});
|
||
|
||
function openSpeciesPopup(sp) {
|
||
currentSpecies = sp;
|
||
document.getElementById('pp-icon').innerHTML = sp.icon || '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>';
|
||
document.getElementById('pp-name').textContent = sp.name_ru;
|
||
document.getElementById('pp-lat').textContent = sp.name_lat || '';
|
||
const catEl = document.getElementById('pp-cat');
|
||
catEl.textContent = sp.category;
|
||
catEl.className = `orb-cat cat-${sp.category}`;
|
||
document.getElementById('pp-fact').textContent = sp.interesting_fact || '';
|
||
document.getElementById('pp-desc').textContent = sp.description ? sp.description.slice(0, 180) + '…' : '';
|
||
document.getElementById('bio-popup').classList.add('open');
|
||
}
|
||
function closePopup() {
|
||
document.getElementById('bio-popup').classList.remove('open');
|
||
currentSpecies = null;
|
||
}
|
||
async function collectFromPopup() {
|
||
if (!currentSpecies) return;
|
||
const res = await LS.post(`/api/red-book/species/${currentSpecies.id}/collect`, { method: 'biome' }).catch(() => null);
|
||
if (res) {
|
||
const btn = document.getElementById('pp-collect');
|
||
btn.innerHTML = `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> +${res.xp_earned||20} XP`;
|
||
btn.disabled = true;
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Biome tabs
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function renderTabs() {
|
||
const el = document.getElementById('biome-tabs');
|
||
el.innerHTML = BIOMES.map((b, i) =>
|
||
`<div class="biome-tab ${i === currentBiomeIdx ? 'active' : ''}" onclick="selectBiome(${i})">
|
||
<span class="tab-icon">${b.icon}</span>${b.name}
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
async function selectBiome(idx) {
|
||
currentBiomeIdx = idx;
|
||
renderTabs();
|
||
const biome = BIOMES[idx];
|
||
buildBiomeScene(biome);
|
||
// Ambient sound
|
||
if (soundEnabled) playBiomeSound(biome.type);
|
||
// Re-apply night if active
|
||
if (isNight) {
|
||
ambient.intensity = 0.3; sun.intensity = 0.1;
|
||
renderer.setClearColor(0x030810);
|
||
scene.fog = new THREE.FogExp2(0x050c14, biome.fogDensity * 1.5);
|
||
}
|
||
// Re-apply rain if active
|
||
if (rainEnabled) initRain();
|
||
|
||
// Load species for this habitat
|
||
const habitat = habitats.find(h => h.type === biome.type);
|
||
let species = [];
|
||
if (habitat) {
|
||
const data = await LS.get(`/api/red-book/biome/${habitat.id}`).catch(() => []);
|
||
species = Array.isArray(data) ? data : [];
|
||
}
|
||
speciesList = species;
|
||
|
||
// Update info overlay
|
||
document.getElementById('bio-name').textContent = biome.name;
|
||
document.getElementById('bio-desc').textContent = habitat?.description || 'Биом Беларуси';
|
||
document.getElementById('bio-species-count').textContent = `${species.length} видов`;
|
||
|
||
// Build 3D orbs
|
||
buildSpeciesOrbs(species);
|
||
|
||
// Build HTML orb list
|
||
const orbsEl = document.getElementById('bio-orbs');
|
||
orbsEl.innerHTML = species.length ? species.map(sp =>
|
||
`<div class="orb-card" onclick='openSpeciesPopup(${JSON.stringify(sp).replace(/'/g, "'")})'>
|
||
<span class="orb-icon">${sp.icon || '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>'}</span>
|
||
<div class="orb-info">
|
||
<div class="orb-name">${sp.name_ru}</div>
|
||
<div class="orb-lat">${sp.name_lat || ''}</div>
|
||
</div>
|
||
<span class="orb-cat cat-${sp.category}">${sp.category}</span>
|
||
</div>`
|
||
).join('')
|
||
: '<div style="color:var(--rb-muted);font-size:12px;padding:10px">Нет данных о видах этого биома</div>';
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Render loop
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
/* ── Day/Night cycle ── */
|
||
let isNight = false;
|
||
|
||
function toggleDayNight() {
|
||
isNight = !isNight;
|
||
const btn = document.getElementById('daynight-btn');
|
||
const biome = BIOMES[currentBiomeIdx];
|
||
if (isNight) {
|
||
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg> Ночь';
|
||
btn.style.borderColor = '#818cf8';
|
||
btn.style.color = '#818cf8';
|
||
// Dim lights
|
||
ambient.intensity = 0.3;
|
||
sun.intensity = 0.1;
|
||
sun.color.setHex(0x112244);
|
||
// Darken sky and fog
|
||
renderer.setClearColor(0x030810);
|
||
scene.fog = new THREE.FogExp2(0x050c14, biome.fogDensity * 1.5);
|
||
// Brighter particles (stars/fireflies)
|
||
pmat.size = 0.18;
|
||
pmat.opacity = 0.85;
|
||
pmat.color.setStyle('#aaccff');
|
||
} else {
|
||
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>️ День';
|
||
btn.style.borderColor = 'var(--rb-border)';
|
||
btn.style.color = 'var(--rb-muted)';
|
||
// Restore biome lighting
|
||
ambient.intensity = 1.5;
|
||
sun.intensity = 1.2;
|
||
buildBiomeScene(biome); // resets all colors
|
||
if (soundEnabled) playBiomeSound(biome.type);
|
||
}
|
||
}
|
||
|
||
/* ── Rain system ── */
|
||
let rainEnabled = false;
|
||
let rainGeo, rainMat, rainMesh;
|
||
|
||
function toggleRain() {
|
||
rainEnabled = !rainEnabled;
|
||
const btn = document.getElementById('weather-btn');
|
||
if (rainEnabled) {
|
||
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 14.9A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.24"/><path d="M16 14v6a2 2 0 0 1-4 0"/></svg> Ливень';
|
||
btn.style.borderColor = '#60a5fa';
|
||
btn.style.color = '#60a5fa';
|
||
initRain();
|
||
} else {
|
||
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 14.9A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.24"/><path d="M8 19v1M8 14v1M16 19v1M16 14v1M12 21v1M12 16v1"/></svg> Дождь';
|
||
btn.style.borderColor = 'var(--rb-border)';
|
||
btn.style.color = 'var(--rb-muted)';
|
||
if (rainMesh) { scene.remove(rainMesh); rainMesh = null; }
|
||
// Reduce fog
|
||
const biome = BIOMES[currentBiomeIdx];
|
||
scene.fog = new THREE.FogExp2(biome.fogColor, biome.fogDensity);
|
||
}
|
||
}
|
||
|
||
function initRain() {
|
||
if (rainMesh) scene.remove(rainMesh);
|
||
const RC = 1500;
|
||
const rPos = new Float32Array(RC * 3);
|
||
for (let i = 0; i < RC; i++) {
|
||
rPos[i*3] = (Math.random() - 0.5) * 50;
|
||
rPos[i*3+1] = Math.random() * 20;
|
||
rPos[i*3+2] = (Math.random() - 0.5) * 40 - 5;
|
||
}
|
||
rainGeo = new THREE.BufferGeometry();
|
||
rainGeo.setAttribute('position', new THREE.BufferAttribute(rPos, 3));
|
||
rainMat = new THREE.PointsMaterial({ color: 0x88aaff, size: 0.06, transparent: true, opacity: 0.5 });
|
||
rainMesh = new THREE.Points(rainGeo, rainMat);
|
||
scene.add(rainMesh);
|
||
// Increase fog for rain atmosphere
|
||
const biome = BIOMES[currentBiomeIdx];
|
||
scene.fog = new THREE.FogExp2(biome.fogColor, biome.fogDensity * 2.5);
|
||
}
|
||
|
||
function updateRain() {
|
||
if (!rainEnabled || !rainMesh) return;
|
||
const pos = rainGeo.attributes.position;
|
||
for (let i = 0; i < 1500; i++) {
|
||
pos.array[i*3+1] -= 0.18; // fall down
|
||
pos.array[i*3] -= 0.03; // slight wind
|
||
if (pos.array[i*3+1] < 0) {
|
||
pos.array[i*3+1] = 20;
|
||
pos.array[i*3] = (Math.random() - 0.5) * 50;
|
||
}
|
||
}
|
||
pos.needsUpdate = true;
|
||
}
|
||
|
||
let t = 0, camTheta = 0;
|
||
canvas.addEventListener('mousedown', e => { _drag = true; _px = e.clientX; });
|
||
window.addEventListener('mouseup', () => { _drag = false; });
|
||
let _drag = false, _px = 0;
|
||
window.addEventListener('mousemove', e => {
|
||
if (!_drag) return;
|
||
camTheta -= (e.clientX - _px) * 0.005;
|
||
_px = e.clientX;
|
||
});
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
t += 0.008;
|
||
if (!_drag) camTheta += 0.002;
|
||
camera.position.x = 22 * Math.sin(camTheta);
|
||
camera.position.z = 22 * Math.cos(camTheta);
|
||
camera.lookAt(0, 1, 0);
|
||
|
||
// Animate particles
|
||
const pos = pgeo.attributes.position;
|
||
for (let i = 0; i < PC; i++) {
|
||
pos.array[i*3+1] += Math.sin(t*1.5 + pPhase[i]) * 0.003;
|
||
pos.array[i*3] += Math.cos(t + pPhase[i]*0.7) * 0.002;
|
||
}
|
||
pos.needsUpdate = true;
|
||
pmat.opacity = 0.35 + Math.sin(t*2)*0.15;
|
||
|
||
// Animate orbs
|
||
orbMeshes.forEach(orb => {
|
||
orb.position.y += Math.sin(t*1.5 + orb.userData.phase) * 0.005;
|
||
orb.material.emissiveIntensity = 0.4 + Math.sin(t*2 + orb.userData.phase)*0.25;
|
||
// Animate orbit path
|
||
if (orb.userData.orbit) {
|
||
orb.userData.orbit.t = (orb.userData.orbit.t || 0) + 0.003;
|
||
const ot = orb.userData.orbit.t;
|
||
orb.position.x = orb.userData.orbit.cx + Math.cos(ot) * orb.userData.orbit.r;
|
||
orb.position.z = orb.userData.orbit.cz + Math.sin(ot) * orb.userData.orbit.r;
|
||
}
|
||
});
|
||
|
||
updateRain();
|
||
renderer.render(scene, camera);
|
||
}
|
||
animate();
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Boot
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function toggleSidebar() {
|
||
document.getElementById('app').classList.toggle('sb-collapsed');
|
||
localStorage.setItem('ls_sb_collapsed', document.getElementById('app').classList.contains('sb-collapsed') ? '1' : '0');
|
||
lucide.createIcons();
|
||
setTimeout(resize, 300);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Ambient Sound Engine (Web Audio API synthesis — no files needed)
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
let audioCtx = null;
|
||
let soundEnabled = false;
|
||
let activeNodes = [];
|
||
|
||
// Biome sound profiles: { wind, waterFreq, birdRate, frogRate, insectFreq }
|
||
const SOUND_PROFILES = {
|
||
forest: { windFreq: 300, windGain: 0.06, birdRate: 2.5, waterFreq: 0, frogRate: 0, insectFreq: 0 },
|
||
conifer: { windFreq: 200, windGain: 0.09, birdRate: 1.0, waterFreq: 0, frogRate: 0, insectFreq: 0 },
|
||
wetland: { windFreq: 400, windGain: 0.04, birdRate: 0.5, waterFreq: 800, frogRate: 0.8, insectFreq: 4000 },
|
||
river: { windFreq: 500, windGain: 0.03, birdRate: 1.5, waterFreq: 1200, frogRate: 0, insectFreq: 0 },
|
||
meadow: { windFreq: 600, windGain: 0.05, birdRate: 3.0, waterFreq: 0, frogRate: 0, insectFreq: 2800 },
|
||
};
|
||
|
||
function ensureAudio() {
|
||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||
}
|
||
|
||
function stopAllSound() {
|
||
activeNodes.forEach(n => { try { n.stop?.(); n.disconnect?.(); } catch {} });
|
||
activeNodes = [];
|
||
}
|
||
|
||
function makeNoise(ctx) {
|
||
const bufSize = ctx.sampleRate * 2;
|
||
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
|
||
const data = buf.getChannelData(0);
|
||
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
src.loop = true;
|
||
return src;
|
||
}
|
||
|
||
function playBiomeSound(biomeType) {
|
||
if (!soundEnabled) return;
|
||
ensureAudio();
|
||
stopAllSound();
|
||
const ctx = audioCtx;
|
||
const prof = SOUND_PROFILES[biomeType] || SOUND_PROFILES.forest;
|
||
|
||
// Wind: filtered white noise
|
||
if (prof.windGain > 0) {
|
||
const noise = makeNoise(ctx);
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = 'bandpass';
|
||
filter.frequency.value = prof.windFreq;
|
||
filter.Q.value = 0.5;
|
||
const gain = ctx.createGain();
|
||
gain.gain.value = prof.windGain;
|
||
// slow LFO on gain for natural breathing
|
||
const lfo = ctx.createOscillator();
|
||
lfo.frequency.value = 0.15;
|
||
const lfoGain = ctx.createGain();
|
||
lfoGain.gain.value = prof.windGain * 0.4;
|
||
lfo.connect(lfoGain);
|
||
lfoGain.connect(gain.gain);
|
||
noise.connect(filter); filter.connect(gain); gain.connect(ctx.destination);
|
||
noise.start(); lfo.start();
|
||
activeNodes.push(noise, lfo);
|
||
}
|
||
|
||
// Water: higher-pitched filtered noise
|
||
if (prof.waterFreq > 0) {
|
||
const noise = makeNoise(ctx);
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = 'highpass';
|
||
filter.frequency.value = prof.waterFreq;
|
||
const gain = ctx.createGain();
|
||
gain.gain.value = 0.07;
|
||
noise.connect(filter); filter.connect(gain); gain.connect(ctx.destination);
|
||
noise.start();
|
||
activeNodes.push(noise);
|
||
}
|
||
|
||
// Insects: high-pitched drone
|
||
if (prof.insectFreq > 0) {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sawtooth';
|
||
osc.frequency.value = prof.insectFreq;
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = 'lowpass';
|
||
filter.frequency.value = prof.insectFreq + 200;
|
||
const gain = ctx.createGain();
|
||
gain.gain.value = 0.02;
|
||
osc.connect(filter); filter.connect(gain); gain.connect(ctx.destination);
|
||
osc.start();
|
||
activeNodes.push(osc);
|
||
}
|
||
|
||
// Birds: periodic chirps
|
||
if (prof.birdRate > 0) {
|
||
function chirp() {
|
||
if (!soundEnabled) return;
|
||
const t = ctx.currentTime;
|
||
const freq = 1800 + Math.random() * 1200;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(freq, t);
|
||
osc.frequency.linearRampToValueAtTime(freq * 1.3, t + 0.05);
|
||
osc.frequency.linearRampToValueAtTime(freq, t + 0.1);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0, t);
|
||
env.gain.linearRampToValueAtTime(0.08, t + 0.02);
|
||
env.gain.linearRampToValueAtTime(0, t + 0.12);
|
||
osc.connect(env); env.connect(ctx.destination);
|
||
osc.start(t); osc.stop(t + 0.15);
|
||
const next = (1 / prof.birdRate) + Math.random() * 2;
|
||
setTimeout(chirp, next * 1000);
|
||
}
|
||
setTimeout(chirp, 500 + Math.random() * 2000);
|
||
}
|
||
|
||
// Frogs: low pulse
|
||
if (prof.frogRate > 0) {
|
||
function croak() {
|
||
if (!soundEnabled) return;
|
||
const t = ctx.currentTime;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.value = 180 + Math.random() * 40;
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0, t);
|
||
env.gain.linearRampToValueAtTime(0.06, t + 0.03);
|
||
env.gain.linearRampToValueAtTime(0, t + 0.2);
|
||
osc.connect(env); env.connect(ctx.destination);
|
||
osc.start(t); osc.stop(t + 0.25);
|
||
const next = (1 / prof.frogRate) + Math.random() * 1.5;
|
||
setTimeout(croak, next * 1000);
|
||
}
|
||
setTimeout(croak, Math.random() * 1500);
|
||
}
|
||
}
|
||
|
||
function toggleSound() {
|
||
ensureAudio();
|
||
soundEnabled = !soundEnabled;
|
||
const btn = document.getElementById('sound-btn');
|
||
if (soundEnabled) {
|
||
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> Звук';
|
||
btn.style.borderColor = 'var(--rb-accent)';
|
||
btn.style.color = 'var(--rb-accent)';
|
||
const biome = BIOMES[currentBiomeIdx];
|
||
playBiomeSound(biome.type);
|
||
} else {
|
||
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> Звук';
|
||
btn.style.borderColor = 'var(--rb-border)';
|
||
btn.style.color = 'var(--rb-muted)';
|
||
stopAllSound();
|
||
}
|
||
}
|
||
|
||
async function init() {
|
||
LS.hideDisabledFeatures?.();
|
||
lucide.createIcons();
|
||
if (localStorage.getItem('ls_sb_collapsed') === '1') document.getElementById('app').classList.add('sb-collapsed');
|
||
const user = LS.getUser?.();
|
||
if (user) {
|
||
document.getElementById('nav-user').textContent = user.name?.split(' ')[0] || '—';
|
||
document.getElementById('nav-avatar').textContent = (user.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||
}
|
||
habitats = await LS.get('/api/red-book/habitats').catch(() => []);
|
||
renderTabs();
|
||
selectBiome(0);
|
||
}
|
||
init();
|
||
</script>
|
||
<script src="/js/mobile.js"></script>
|
||
</body>
|
||
</html>
|