fecd8bb9c9
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (= "bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex /([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein Logik-Key) -> safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3238 lines
112 KiB
HTML
3238 lines
112 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>Vulkan-Simulation</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Russo+One&family=Outfit:wght@400;500;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
||
html, body {
|
||
font-family: 'Outfit', system-ui, sans-serif;
|
||
background: #87b4d6;
|
||
color: #f5e9d3;
|
||
overflow: hidden;
|
||
height: 100%; width: 100%;
|
||
touch-action: none;
|
||
}
|
||
#canvas-container { position: absolute; inset: 0; }
|
||
canvas { display: block; }
|
||
|
||
.panel {
|
||
position: absolute;
|
||
background: linear-gradient(145deg, rgba(20, 12, 28, 0.92), rgba(12, 8, 18, 0.95));
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
border: 1px solid rgba(255, 140, 66, 0.18);
|
||
border-radius: 14px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05);
|
||
z-index: 10;
|
||
}
|
||
|
||
#ui { top: 10px; left: 10px; padding: 14px 16px; width: 320px; max-width: calc(100% - 20px); }
|
||
|
||
h1 {
|
||
font-family: 'Russo One', sans-serif;
|
||
font-size: 17px;
|
||
letter-spacing: 0.5px;
|
||
color: #ff8c42;
|
||
text-shadow: 0 0 12px rgba(255, 140, 66, 0.4);
|
||
margin-bottom: 12px;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
h1 .dot {
|
||
width: 10px; height: 10px; border-radius: 50%;
|
||
background: radial-gradient(circle at 30% 30%, #ffeb3b, #ff4400);
|
||
box-shadow: 0 0 12px #ff4400;
|
||
animation: pulse 1.5s ease-in-out infinite;
|
||
}
|
||
@keyframes pulse { 0%,100%{transform:scale(1);opacity:1} 50%{transform:scale(1.3);opacity:0.7} }
|
||
|
||
.row { margin-bottom: 10px; }
|
||
.lbl { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; color: #cdb89c; }
|
||
.val { color: #ffaa55; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||
|
||
input[type="range"] {
|
||
width: 100%; height: 22px;
|
||
-webkit-appearance: none; appearance: none;
|
||
background: transparent; outline: none; cursor: pointer;
|
||
}
|
||
input[type="range"]::-webkit-slider-runnable-track {
|
||
height: 6px; background: linear-gradient(90deg,#2a1c2e,#3a2820);
|
||
border-radius: 3px; border: 1px solid rgba(255,140,66,0.15);
|
||
}
|
||
input[type="range"]::-moz-range-track {
|
||
height: 6px; background: linear-gradient(90deg,#2a1c2e,#3a2820);
|
||
border-radius: 3px; border: 1px solid rgba(255,140,66,0.15);
|
||
}
|
||
input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none; appearance: none;
|
||
width: 20px; height: 20px;
|
||
background: radial-gradient(circle at 30% 30%, #ffeb3b, #ff8c42 50%, #cc4400);
|
||
border-radius: 50%; cursor: pointer;
|
||
box-shadow: 0 0 10px rgba(255,140,66,0.6), 0 2px 4px rgba(0,0,0,0.5);
|
||
margin-top: -8px; border: 1px solid rgba(255,200,100,0.5);
|
||
}
|
||
input[type="range"]::-moz-range-thumb {
|
||
width: 20px; height: 20px;
|
||
background: radial-gradient(circle at 30% 30%, #ffeb3b, #ff8c42 50%, #cc4400);
|
||
border-radius: 50%; cursor: pointer; border: 1px solid rgba(255,200,100,0.5);
|
||
box-shadow: 0 0 10px rgba(255,140,66,0.6);
|
||
}
|
||
|
||
#info-panel { margin-top: 12px; padding-top: 11px; border-top: 1px solid rgba(255,140,66,0.15); }
|
||
.info-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 5px; }
|
||
.info-label { color: #9c8870; }
|
||
.info-value { color: #ffd4a3; font-weight: 700; font-family: 'Russo One', sans-serif; letter-spacing: 0.3px; }
|
||
|
||
#expl-bar { height: 8px; background: #1a1018; border-radius: 4px; overflow: hidden;
|
||
margin-top: 8px; border: 1px solid rgba(255,140,66,0.15); }
|
||
#expl-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg,#4caf50 0%,#ffeb3b 35%,#ff9800 65%,#f44336 100%);
|
||
transition: width 0.4s; border-radius: 3px; box-shadow: 0 0 8px rgba(255,140,66,0.5);
|
||
}
|
||
|
||
#erupt-btn {
|
||
width: 100%; margin-top: 12px; padding: 12px;
|
||
background: linear-gradient(135deg,#ff4400,#aa1100);
|
||
color: white; border: 1px solid rgba(255,200,100,0.3);
|
||
border-radius: 10px;
|
||
font-family: 'Russo One', sans-serif; font-size: 14px; letter-spacing: 1px;
|
||
cursor: pointer;
|
||
box-shadow: 0 4px 16px rgba(255,68,0,0.4), inset 0 1px 0 rgba(255,200,100,0.3);
|
||
}
|
||
#erupt-btn:active { transform: scale(0.97); }
|
||
#erupt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
#legend {
|
||
bottom: 10px; right: 10px; padding: 8px 11px; font-size: 11px; min-width: 130px;
|
||
}
|
||
#views {
|
||
top: 10px; right: 10px;
|
||
padding: 5px;
|
||
display: flex;
|
||
gap: 3px;
|
||
}
|
||
#views button, #toggles button {
|
||
background: linear-gradient(135deg, rgba(50,30,60,0.8), rgba(30,20,40,0.8));
|
||
border: 1px solid rgba(255,140,66,0.18);
|
||
color: #cdb89c;
|
||
font-family: 'Russo One', sans-serif;
|
||
font-size: 11px;
|
||
letter-spacing: 0.5px;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: background 0.2s, color 0.2s;
|
||
}
|
||
#views button:hover, #toggles button:hover { color: #ffd4a3; }
|
||
#views button.active, #toggles button.active {
|
||
background: linear-gradient(135deg, rgba(255,140,66,0.4), rgba(200,80,40,0.4));
|
||
color: #fff;
|
||
border-color: rgba(255,200,100,0.5);
|
||
}
|
||
#toggles {
|
||
top: 56px; right: 10px;
|
||
padding: 5px;
|
||
display: flex;
|
||
gap: 3px;
|
||
}
|
||
#slice-controls {
|
||
position: absolute;
|
||
bottom: 24px; left: 50%;
|
||
transform: translateX(-50%);
|
||
display: none;
|
||
align-items: center;
|
||
gap: 14px;
|
||
background: linear-gradient(135deg, rgba(40,25,55,0.92), rgba(25,18,40,0.92));
|
||
border: 1px solid rgba(255,140,66,0.25);
|
||
padding: 12px 22px;
|
||
border-radius: 10px;
|
||
color: #ffd4a3;
|
||
font-family: 'Russo One', sans-serif;
|
||
z-index: 20;
|
||
}
|
||
#slice-controls .slice-label {
|
||
font-size: 11px;
|
||
letter-spacing: 1.2px;
|
||
color: #ffaa66;
|
||
}
|
||
#slice-pos {
|
||
width: 280px;
|
||
accent-color: #ff8844;
|
||
}
|
||
#slice-value {
|
||
min-width: 36px;
|
||
text-align: right;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
}
|
||
#cs-panel {
|
||
position: absolute;
|
||
top: 110px; right: 20px;
|
||
display: none;
|
||
background: linear-gradient(135deg, rgba(40,25,55,0.92), rgba(20,15,30,0.92));
|
||
border: 1px solid rgba(255,140,66,0.30);
|
||
padding: 12px;
|
||
border-radius: 10px;
|
||
font-family: 'Russo One', sans-serif;
|
||
z-index: 15;
|
||
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
|
||
}
|
||
#cs-title {
|
||
color: #ffaa66;
|
||
font-size: 11px;
|
||
letter-spacing: 1.4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
#cs-canvas {
|
||
display: block;
|
||
border-radius: 6px;
|
||
background: #c2dfee;
|
||
}
|
||
#cs-legend {
|
||
display: flex;
|
||
gap: 14px;
|
||
margin-top: 8px;
|
||
font-size: 11px;
|
||
color: #cdb89c;
|
||
letter-spacing: 0.8px;
|
||
}
|
||
.cs-key {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
.cs-dot {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
.legend-title { font-family: 'Russo One', sans-serif; color: #ff8c42; font-size: 11px;
|
||
margin-bottom: 4px; letter-spacing: 0.5px; }
|
||
.legend-row { display: flex; align-items: center; gap: 7px; margin: 3px 0; color: #cdb89c; }
|
||
.legend-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||
|
||
#didactic {
|
||
bottom: 10px; left: 10px; padding: 10px 13px; font-size: 12px; max-width: 290px; line-height: 1.4;
|
||
}
|
||
#didactic-title { font-family: 'Russo One', sans-serif; color: #ff8c42; font-size: 12px;
|
||
margin-bottom: 5px; letter-spacing: 0.5px; }
|
||
#didactic-text { color: #ddc8a8; }
|
||
#didactic-text strong { color: #ffd4a3; }
|
||
|
||
@media (max-width: 520px) {
|
||
#ui { padding: 12px 13px; }
|
||
h1 { font-size: 16px; }
|
||
#didactic { display: none; }
|
||
#legend { font-size: 10px; padding: 6px 8px; min-width: 110px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="canvas-container"></div>
|
||
|
||
<div id="ui" class="panel">
|
||
<h1><span class="dot"></span>VULKAN-SIMULATION</h1>
|
||
|
||
<div class="row">
|
||
<div class="lbl"><span>Vulkantyp</span><span class="val" id="type-val">Schildvulkan</span></div>
|
||
<input type="range" id="type" min="0" max="100" value="0">
|
||
</div>
|
||
<div class="row">
|
||
<div class="lbl"><span>Gasdruck</span><span class="val" id="gas-val">50 %</span></div>
|
||
<input type="range" id="gas" min="0" max="100" value="50">
|
||
</div>
|
||
<div class="row">
|
||
<div class="lbl"><span>Magma-Zähigkeit</span><span class="val" id="visc-val">niedrig</span></div>
|
||
<input type="range" id="viscosity" min="0" max="100" value="15">
|
||
</div>
|
||
<div class="row">
|
||
<div class="lbl"><span>Wasseranteil</span><span class="val" id="water-val">10 %</span></div>
|
||
<input type="range" id="water" min="0" max="100" value="10">
|
||
</div>
|
||
|
||
<div id="info-panel">
|
||
<div class="info-row"><span class="info-label">Magma-Art</span><span class="info-value" id="magma-type">BASALTISCH</span></div>
|
||
<div class="info-row"><span class="info-label">Eruptionstyp</span><span class="info-value" id="eruption-type">EFFUSIV</span></div>
|
||
<div class="info-row"><span class="info-label">Explosivität (VEI)</span><span class="info-value" id="vei">0 – 1</span></div>
|
||
<div id="expl-bar"><div id="expl-fill" style="width:15%"></div></div>
|
||
</div>
|
||
|
||
<button id="erupt-btn">🔥 AUSBRUCH AUSLÖSEN</button>
|
||
</div>
|
||
|
||
<div id="views" class="panel">
|
||
<button data-view="default" class="active">Standard</button>
|
||
<button data-view="slice">Schnitt 2D</button>
|
||
<button data-view="side">Seite</button>
|
||
<button data-view="far">Weit</button>
|
||
</div>
|
||
|
||
<div id="toggles" class="panel">
|
||
<button id="toggle-water" class="active">💧 Voll</button>
|
||
<button id="toggle-cross">✂️ Querschnitt</button>
|
||
</div>
|
||
|
||
<div id="slice-controls">
|
||
<span class="slice-label">SCHNITT-POSITION</span>
|
||
<input type="range" id="slice-pos" min="-180" max="180" step="2" value="0">
|
||
<span id="slice-value">0</span>
|
||
</div>
|
||
|
||
<div id="cs-panel">
|
||
<div id="cs-title">🌋 GEOLOGISCHER QUERSCHNITT</div>
|
||
<canvas id="cs-canvas" width="420" height="320"></canvas>
|
||
<div id="cs-legend">
|
||
<span class="cs-key"><span class="cs-dot" style="background:#ff8030"></span>Magma</span>
|
||
<span class="cs-key"><span class="cs-dot" style="background:#7a5530"></span>Gestein</span>
|
||
<span class="cs-key"><span class="cs-dot" style="background:#3d83ab"></span>Meer</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="legend" class="panel">
|
||
<div class="legend-title">LEGENDE</div>
|
||
<div class="legend-row"><div class="legend-dot" style="background:#ff6622"></div>Lava (heiß)</div>
|
||
<div class="legend-row"><div class="legend-dot" style="background:#1a0e08"></div>Erstarrte Lava</div>
|
||
<div class="legend-row"><div class="legend-dot" style="background:#a07033"></div>Verwittertes Gestein</div>
|
||
<div class="legend-row"><div class="legend-dot" style="background:#3d7a28"></div>Bewachsenes Land</div>
|
||
<div class="legend-row"><div class="legend-dot" style="background:#555"></div>Aschewolke</div>
|
||
<div class="legend-row"><div class="legend-dot" style="background:#3a2820"></div>Pyroklast. Strom</div>
|
||
<div class="legend-row"><div class="legend-dot" style="background:#ff0044"></div>Gefahrenzone</div>
|
||
</div>
|
||
|
||
<div id="didactic" class="panel">
|
||
<div id="didactic-title">💡 ZUSAMMENHANG</div>
|
||
<div id="didactic-text">Niedrige Viskosität → Gas entweicht leicht → <strong>ruhiger Lavafluss</strong>.</div>
|
||
</div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||
<script>
|
||
// ============================================================================
|
||
// VULKAN-SIMULATION — vollständige Neuimplementierung mit Sprite-Partikeln
|
||
// ============================================================================
|
||
|
||
// --- Globals ---
|
||
let scene, camera, renderer, clock;
|
||
let volcano, lavaCap, sea, craterLight;
|
||
let sky;
|
||
let explosionFlash, shockRing;
|
||
|
||
// Vulkan-Profil
|
||
let profilePts = []; // [{r, y}, ...] vom Fuß zum Krater
|
||
|
||
// Parameter
|
||
const params = {
|
||
type: 0,
|
||
gas: 0.5,
|
||
viscosity: 0.15,
|
||
water: 0.10,
|
||
};
|
||
|
||
// Eruption-State
|
||
const erupt = {
|
||
active: false,
|
||
t: 0,
|
||
duration: 18, // länger → mehr Material insgesamt
|
||
};
|
||
let flashTime = 0, ringTime = 0, ringMaxR = 0, shakeAmount = 0;
|
||
|
||
// Terrain (Höhenfeld)
|
||
let terrain;
|
||
let terrainAge; // age in seconds per vertex
|
||
let terrainHeight; // current y per vertex
|
||
let terrainDistFromCrater; // precomputed distance per vertex
|
||
let terrainDirty = false;
|
||
const TGRID = 128;
|
||
const TSIZE = 600;
|
||
const TBASE_Y = -1.5; // Meeresboden — Sea bei -1.0 → Boden 0.5 unter Wasserspiegel
|
||
|
||
// Vegetation / Siedlung
|
||
let trees, houses, churches;
|
||
let treeData = [], houseData = [], churchData = [];
|
||
let animals = [];
|
||
let lastSpawnAttempt = 0;
|
||
const MAX_TREES = 500;
|
||
const MAX_HOUSES = 80;
|
||
const MAX_CHURCHES = 20;
|
||
const MAX_ANIMALS = 25;
|
||
|
||
// Lava-Strömungskanäle (3-4 pro Eruption)
|
||
let flowChannels = [];
|
||
// Aktueller Eruptions-Sektor (Mittelpunkt + Bogen) — bestimmt welche Seite betroffen ist
|
||
let eruptionSector = null;
|
||
// Visuelle Anzeige des betroffenen Sektors (Fan-Form am Boden)
|
||
let sectorOverlay = null;
|
||
// Sichtbare Lava-Ströme als flache Bänder, die auf dem Terrain liegen
|
||
let lavaStreams = [];
|
||
|
||
// Vulkan-Evolution: beginnt als kleine Insel und wächst REAKTIV auf die Umgebung
|
||
let volcanoGrowth = 0.05; // Anfangs gerade so aus dem Wasser
|
||
let eruptionCount = 0;
|
||
|
||
// Sichtbarkeit-Schalter
|
||
let waterVisible = true;
|
||
let waterPreference = true;
|
||
let crossSectionActive = false;
|
||
let magmaChamber, magmaConduit;
|
||
const clipPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), 0);
|
||
|
||
// MRT-Slice-View: orthografische Kamera + Slab aus zwei Clipping-Planes
|
||
let sliceMode = false;
|
||
let slicePos = 0;
|
||
let orthoCamera = null;
|
||
const SLAB_THICKNESS = 4;
|
||
const clipSliceFront = new THREE.Plane(new THREE.Vector3(0, 0, -1), 0);
|
||
const clipSliceBack = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
||
|
||
// Kamera-Animation (sanfter Übergang zwischen Presets)
|
||
let camTarget = null;
|
||
|
||
// Sprite-Pools
|
||
const lavaPool = [];
|
||
const ashPool = [];
|
||
const pyroPool = [];
|
||
const steamPool = [];
|
||
const burnSmokePool = [];
|
||
const skullPool = [];
|
||
|
||
// Wolken, Vögel, Gefahrenringe
|
||
let clouds = [];
|
||
let birds = [];
|
||
let dangerRings = [];
|
||
|
||
// Kamera-Orbit
|
||
const cam = {
|
||
azimuth: Math.PI * 0.25,
|
||
elevation: 0.35,
|
||
distance: 300,
|
||
target: new THREE.Vector3(0, 20, 0),
|
||
};
|
||
|
||
// ============================================================================
|
||
// PROFIL & HÖHENFUNKTION
|
||
// ============================================================================
|
||
function volcanoProfile(t, type) {
|
||
// Vulkan wächst mit jeder Eruption — beginnt als flache kleine Insel
|
||
const sizeScale = 0.18 + volcanoGrowth * 0.82; // 0.18 anfangs → 1.0 voll ausgewachsen
|
||
const height = (30 + type * 45) * sizeScale;
|
||
const baseRadius = (140 - type * 45) * sizeScale;
|
||
const craterR = (6 + type * 5) * Math.max(0.45, sizeScale);
|
||
const p = 0.30 * (1-type) + 1.25 * type;
|
||
const r = craterR + (baseRadius - craterR) * Math.pow(1-t, p);
|
||
return { r, y: height * t };
|
||
}
|
||
|
||
function rebuildProfile() {
|
||
profilePts = [];
|
||
for (let i = 0; i <= 60; i++) profilePts.push(volcanoProfile(i/60, params.type));
|
||
}
|
||
|
||
function maxHeight() { return profilePts[profilePts.length - 1].y; }
|
||
function baseRadius() { return profilePts[0].r; }
|
||
function craterRadius() { return profilePts[profilePts.length - 1].r; }
|
||
|
||
function heightAtRadius(r) {
|
||
if (r > profilePts[0].r) return 0;
|
||
for (let i = 0; i < profilePts.length - 1; i++) {
|
||
const a = profilePts[i], b = profilePts[i + 1];
|
||
if (r <= a.r && r >= b.r) {
|
||
const f = (a.r - r) / Math.max(0.001, a.r - b.r);
|
||
return a.y * (1 - f) + b.y * f;
|
||
}
|
||
}
|
||
return profilePts[profilePts.length - 1].y;
|
||
}
|
||
|
||
// ============================================================================
|
||
// TEXTUR-HELFER (Canvas-basiert, robust)
|
||
// ============================================================================
|
||
function radialTexture(stops, size = 64) {
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = size;
|
||
const ctx = c.getContext('2d');
|
||
const g = ctx.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
|
||
for (const [pos, col] of stops) g.addColorStop(pos, col);
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, size, size);
|
||
const tex = new THREE.CanvasTexture(c);
|
||
return tex;
|
||
}
|
||
|
||
const TEX_LAVA = () => radialTexture([
|
||
[0,'rgba(255,250,200,1)'], [0.3,'rgba(255,180,60,0.95)'],
|
||
[0.65,'rgba(255,90,30,0.6)'], [1,'rgba(180,40,0,0)']
|
||
], 64);
|
||
const TEX_ASH = () => radialTexture([
|
||
[0,'rgba(120,115,110,0.9)'], [0.5,'rgba(80,75,70,0.55)'], [1,'rgba(60,55,50,0)']
|
||
], 64);
|
||
const TEX_PYRO = () => radialTexture([
|
||
[0,'rgba(70,40,30,0.95)'], [0.4,'rgba(120,55,30,0.7)'],
|
||
[0.8,'rgba(60,30,20,0.3)'], [1,'rgba(40,20,15,0)']
|
||
], 64);
|
||
const TEX_STEAM = () => radialTexture([
|
||
[0,'rgba(255,255,255,0.85)'], [0.5,'rgba(240,240,240,0.4)'], [1,'rgba(220,220,220,0)']
|
||
], 64);
|
||
const TEX_CLOUD = () => radialTexture([
|
||
[0,'rgba(255,255,255,0.95)'], [0.5,'rgba(255,255,255,0.5)'], [1,'rgba(255,255,255,0)']
|
||
], 128);
|
||
|
||
const TEX_BURN_SMOKE = () => radialTexture([
|
||
[0,'rgba(40,40,40,0.95)'], [0.4,'rgba(25,25,25,0.6)'], [1,'rgba(15,15,15,0)']
|
||
], 64);
|
||
|
||
const TEX_SKULL = () => {
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = 128;
|
||
const x = c.getContext('2d');
|
||
x.font = '110px sans-serif';
|
||
x.textAlign = 'center';
|
||
x.textBaseline = 'middle';
|
||
// Schatten für Lesbarkeit
|
||
x.fillStyle = 'rgba(0,0,0,0.7)';
|
||
x.fillText('☠', 65, 67);
|
||
x.fillStyle = '#fff';
|
||
x.fillText('☠', 64, 64);
|
||
const tex = new THREE.CanvasTexture(c);
|
||
tex.needsUpdate = true;
|
||
return tex;
|
||
};
|
||
const TEX_SUN = () => radialTexture([
|
||
[0,'rgba(255,250,220,1)'], [0.25,'rgba(255,225,140,0.85)'],
|
||
[0.6,'rgba(255,200,100,0.3)'], [1,'rgba(255,200,100,0)']
|
||
], 128);
|
||
|
||
// ============================================================================
|
||
// INIT
|
||
// ============================================================================
|
||
function init() {
|
||
scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x9fc4e3);
|
||
scene.fog = new THREE.Fog(0xc4dae8, 400, 900);
|
||
|
||
camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 1, 2000);
|
||
updateCameraPos();
|
||
|
||
// Orthographische Kamera für MRT-Slice-Modus
|
||
const aspect = window.innerWidth / window.innerHeight;
|
||
const orthoSize = 115;
|
||
orthoCamera = new THREE.OrthographicCamera(
|
||
-orthoSize * aspect, orthoSize * aspect, orthoSize, -orthoSize, 0.1, 2000
|
||
);
|
||
orthoCamera.position.set(0, -8, 400);
|
||
orthoCamera.lookAt(0, -8, 0);
|
||
|
||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||
|
||
// Beleuchtung
|
||
scene.add(new THREE.AmbientLight(0xbbd0e0, 0.85));
|
||
const sun = new THREE.DirectionalLight(0xfff1c8, 1.1);
|
||
sun.position.set(200, 280, 150);
|
||
scene.add(sun);
|
||
scene.add(new THREE.HemisphereLight(0x88aacc, 0x55452e, 0.4));
|
||
|
||
// Kraterlicht (dynamisch)
|
||
craterLight = new THREE.PointLight(0xff5500, 0, 250, 2);
|
||
craterLight.position.set(0, 50, 0);
|
||
scene.add(craterLight);
|
||
|
||
createSky();
|
||
createSun();
|
||
createSea();
|
||
createTerrain();
|
||
buildVolcano();
|
||
createInternalMagma();
|
||
createDangerRings();
|
||
createSectorOverlay();
|
||
createExplosionEffects();
|
||
|
||
// Sprite-Pools anlegen
|
||
fillPool(lavaPool, 150, TEX_LAVA(), 0xff7733);
|
||
fillPool(ashPool, 500, TEX_ASH(), 0x808080);
|
||
fillPool(pyroPool, 90, TEX_PYRO(), 0x6a3020);
|
||
fillPool(steamPool, 40, TEX_STEAM(), 0xffffff);
|
||
fillPool(burnSmokePool, 40, TEX_BURN_SMOKE(), 0x1a1a1a);
|
||
fillPool(skullPool, 12, TEX_SKULL(), 0xffffff);
|
||
|
||
createTrees();
|
||
createHouses();
|
||
createChurches();
|
||
createAnimals();
|
||
|
||
createClouds();
|
||
createBirds();
|
||
|
||
clock = new THREE.Clock();
|
||
setupUI();
|
||
setupControls();
|
||
updateInfo();
|
||
|
||
window.addEventListener('resize', onResize);
|
||
}
|
||
|
||
// ============================================================================
|
||
// SPRITE-POOL (zentrale Komponente — einfach und robust)
|
||
// ============================================================================
|
||
function fillPool(pool, count, tex, color) {
|
||
for (let i = 0; i < count; i++) {
|
||
const mat = new THREE.SpriteMaterial({
|
||
map: tex,
|
||
color: color,
|
||
transparent: true,
|
||
opacity: 0,
|
||
depthWrite: false,
|
||
});
|
||
const s = new THREE.Sprite(mat);
|
||
s.scale.set(0.01, 0.01, 1);
|
||
s.visible = false;
|
||
s.userData = {
|
||
active: false, life: 0, maxLife: 1,
|
||
vx: 0, vy: 0, vz: 0,
|
||
ang: 0, rad: 0,
|
||
ballistic: false,
|
||
sizeStart: 1, sizeEnd: 1,
|
||
};
|
||
scene.add(s);
|
||
pool.push(s);
|
||
}
|
||
}
|
||
|
||
function getFreeSprite(pool) {
|
||
for (let i = 0; i < pool.length; i++) {
|
||
if (!pool[i].userData.active) return pool[i];
|
||
}
|
||
// Pool voll → ältestes recyceln
|
||
return pool[0];
|
||
}
|
||
|
||
// ============================================================================
|
||
// VULKAN-MESH
|
||
// ============================================================================
|
||
function buildVolcano() {
|
||
rebuildProfile();
|
||
if (volcano) { scene.remove(volcano); volcano.geometry.dispose(); }
|
||
if (lavaCap) { scene.remove(lavaCap); lavaCap.geometry.dispose(); }
|
||
|
||
const pts = profilePts.map(p => new THREE.Vector2(p.r, p.y));
|
||
const geom = new THREE.LatheGeometry(pts, 48);
|
||
geom.computeVertexNormals();
|
||
|
||
// Pro-Vertex-Farbe nach Höhe
|
||
const top = maxHeight();
|
||
const colors = new Float32Array(geom.attributes.position.count * 3);
|
||
for (let i = 0; i < geom.attributes.position.count; i++) {
|
||
const y = geom.attributes.position.getY(i);
|
||
const h = y / top;
|
||
let r, g, b;
|
||
if (h < 0.3) { r = 0.22 + params.type*0.08; g = 0.30 - params.type*0.10; b = 0.10; }
|
||
else if (h < 0.7) { r = 0.36 - params.type*0.10; g = 0.26 - params.type*0.07; b = 0.16; }
|
||
else { r = 0.30 - params.type*0.12; g = 0.22 - params.type*0.09; b = 0.18; }
|
||
colors[i*3] = r; colors[i*3+1] = g; colors[i*3+2] = b;
|
||
}
|
||
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
|
||
volcano = new THREE.Mesh(geom, new THREE.MeshStandardMaterial({
|
||
vertexColors: true, roughness: 0.95, metalness: 0.05,
|
||
}));
|
||
scene.add(volcano);
|
||
|
||
// Glühender Lava-See im Krater
|
||
const capR = craterRadius() * 0.82;
|
||
const capGeom = new THREE.CircleGeometry(capR, 24);
|
||
lavaCap = new THREE.Mesh(capGeom, new THREE.MeshBasicMaterial({
|
||
color: 0xff5500, transparent: true, opacity: 0.9,
|
||
}));
|
||
lavaCap.rotation.x = -Math.PI/2;
|
||
lavaCap.position.y = top - 0.3;
|
||
scene.add(lavaCap);
|
||
|
||
cam.target.y = top * 0.4;
|
||
updateCameraPos();
|
||
|
||
// Conduit innen mit aktualisierter Vulkanhöhe neu bauen
|
||
if (magmaConduit) rebuildConduit();
|
||
if (magmaChamberCap !== undefined) rebuildVolcanoCap();
|
||
// Bestehende Terrain-Höhen an neuen Vulkan-Cap anpassen — keine Wülste höher als der Kegel
|
||
if (terrain) clampTerrainToVolcano();
|
||
}
|
||
|
||
// Innenleben des Vulkans (nur sichtbar im Querschnitt-Modus)
|
||
let magmaChamberCap, magmaConduitCap, volcanoCap;
|
||
let magmaBubbles = []; // Blubbern im Conduit während Querschnitt
|
||
|
||
function createInternalMagma() {
|
||
// Magmakammer als unregelmäßiger Blob (keine Kugel)
|
||
// Reale Magmakammern sind verzweigt und irregulär
|
||
const chamberGeom = new THREE.SphereGeometry(38, 40, 26);
|
||
const cPos = chamberGeom.attributes.position;
|
||
for (let i = 0; i < cPos.count; i++) {
|
||
const x = cPos.getX(i), y = cPos.getY(i), z = cPos.getZ(i);
|
||
const r = Math.sqrt(x*x + y*y + z*z);
|
||
// Mehrere Oktaven für realistische Blob-Deformation
|
||
const noise =
|
||
Math.sin(x*0.08) * 4 +
|
||
Math.cos(y*0.06) * 6 +
|
||
Math.sin(z*0.07) * 5 +
|
||
Math.sin((x+y+z)*0.04) * 3 +
|
||
Math.cos((x-z)*0.05) * 4;
|
||
if (r > 0.1) {
|
||
const f = (r + noise) / r;
|
||
cPos.setX(i, x * f);
|
||
cPos.setY(i, y * f * 0.88); // etwas abgeflacht — wie linsenförmige Magmakammern
|
||
cPos.setZ(i, z * f * 1.05); // leicht gestreckt
|
||
}
|
||
}
|
||
chamberGeom.computeVertexNormals();
|
||
|
||
// Per-Vertex-Farbverlauf
|
||
const cColors = new Float32Array(cPos.count * 3);
|
||
for (let i = 0; i < cPos.count; i++) {
|
||
const y = cPos.getY(i);
|
||
const t = Math.max(0, Math.min(1, (y + 48) / 96));
|
||
cColors[i*3] = 1.0;
|
||
cColors[i*3+1] = 0.18 + t * 0.50;
|
||
cColors[i*3+2] = 0.02 + t * 0.10;
|
||
}
|
||
chamberGeom.setAttribute('color', new THREE.BufferAttribute(cColors, 3));
|
||
|
||
magmaChamber = new THREE.Mesh(chamberGeom, new THREE.MeshBasicMaterial({
|
||
vertexColors: true,
|
||
side: THREE.DoubleSide, // beide Seiten sichtbar bei Schnitt
|
||
}));
|
||
magmaChamber.position.set(0, -68, 0);
|
||
magmaChamber.visible = false;
|
||
scene.add(magmaChamber);
|
||
|
||
// Cap für Magmakammer — flache Kreisscheibe füllt die Schnittfläche
|
||
const chCapGeom = new THREE.CircleGeometry(36, 40);
|
||
const chCapColors = new Float32Array(chCapGeom.attributes.position.count * 3);
|
||
for (let i = 0; i < chCapGeom.attributes.position.count; i++) {
|
||
const x = chCapGeom.attributes.position.getX(i);
|
||
const y = chCapGeom.attributes.position.getY(i);
|
||
const d = Math.sqrt(x*x + y*y) / 36;
|
||
// Mitte heller (heißes Zentrum), Rand dunkler
|
||
const heat = 1 - d * 0.4;
|
||
chCapColors[i*3] = 1.0 * heat;
|
||
chCapColors[i*3+1] = (0.55 - d * 0.25) * heat;
|
||
chCapColors[i*3+2] = (0.10 - d * 0.05) * heat;
|
||
}
|
||
chCapGeom.setAttribute('color', new THREE.BufferAttribute(chCapColors, 3));
|
||
// WICHTIG: clippingPlanes:[] schützt die Caps vor dem globalen Clipping —
|
||
// sonst werden sie selbst weggeschnitten und der Querschnitt sieht leer aus.
|
||
magmaChamberCap = new THREE.Mesh(chCapGeom, new THREE.MeshBasicMaterial({
|
||
vertexColors: true,
|
||
side: THREE.DoubleSide,
|
||
clippingPlanes: [],
|
||
}));
|
||
magmaChamberCap.visible = false;
|
||
magmaChamberCap.renderOrder = 2;
|
||
scene.add(magmaChamberCap);
|
||
|
||
// Conduit-Platzhalter
|
||
magmaConduit = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(4, 8, 10, 14),
|
||
new THREE.MeshBasicMaterial({ color: 0xff5511, side: THREE.DoubleSide })
|
||
);
|
||
magmaConduit.visible = false;
|
||
scene.add(magmaConduit);
|
||
|
||
// Conduit-Cap (wird in rebuildConduit dimensioniert) — clippingPlanes:[] gegen Clipping
|
||
magmaConduitCap = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(10, 10),
|
||
new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide, clippingPlanes: [] })
|
||
);
|
||
magmaConduitCap.visible = false;
|
||
magmaConduitCap.renderOrder = 3; // über Vulkan-Cap rendern
|
||
scene.add(magmaConduitCap);
|
||
|
||
rebuildConduit();
|
||
}
|
||
|
||
function rebuildConduit() {
|
||
if (!magmaConduit) return;
|
||
scene.remove(magmaConduit);
|
||
magmaConduit.geometry.dispose();
|
||
|
||
const top = maxHeight();
|
||
const chamberTopY = -30;
|
||
const conduitBottomY = chamberTopY - 6;
|
||
const conduitTopY = top + 2;
|
||
const h = conduitTopY - conduitBottomY;
|
||
|
||
const geom = new THREE.CylinderGeometry(3.5, 7, h, 14);
|
||
geom.translate(0, conduitBottomY + h/2, 0);
|
||
|
||
const colors = new Float32Array(geom.attributes.position.count * 3);
|
||
for (let i = 0; i < geom.attributes.position.count; i++) {
|
||
const y = geom.attributes.position.getY(i);
|
||
const tt = (y - conduitBottomY) / h;
|
||
colors[i*3] = 1.0;
|
||
colors[i*3+1] = 0.25 + tt * 0.55;
|
||
colors[i*3+2] = 0.05 + tt * 0.15;
|
||
}
|
||
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
|
||
magmaConduit = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({
|
||
vertexColors: true,
|
||
side: THREE.DoubleSide,
|
||
}));
|
||
magmaConduit.visible = crossSectionActive;
|
||
scene.add(magmaConduit);
|
||
|
||
// Conduit-Cap als Trapez (Querschnitt durch den Cone)
|
||
if (magmaConduitCap) {
|
||
magmaConduitCap.geometry.dispose();
|
||
const shape = new THREE.Shape();
|
||
shape.moveTo(-7, conduitBottomY);
|
||
shape.lineTo(7, conduitBottomY);
|
||
shape.lineTo(3.5, conduitTopY);
|
||
shape.lineTo(-3.5, conduitTopY);
|
||
shape.closePath();
|
||
const ccGeom = new THREE.ShapeGeometry(shape);
|
||
const cColors = new Float32Array(ccGeom.attributes.position.count * 3);
|
||
for (let i = 0; i < ccGeom.attributes.position.count; i++) {
|
||
const x = ccGeom.attributes.position.getX(i);
|
||
const y = ccGeom.attributes.position.getY(i);
|
||
const tt = (y - conduitBottomY) / h;
|
||
const centerDist = Math.abs(x) / 7;
|
||
// Mitte heller, Rand etwas dunkler
|
||
const heat = 1 - centerDist * 0.25;
|
||
cColors[i*3] = 1.0 * heat;
|
||
cColors[i*3+1] = (0.30 + tt * 0.55) * heat;
|
||
cColors[i*3+2] = (0.05 + tt * 0.15) * heat;
|
||
}
|
||
ccGeom.setAttribute('color', new THREE.BufferAttribute(cColors, 3));
|
||
magmaConduitCap.geometry = ccGeom;
|
||
magmaConduitCap.material.needsUpdate = true;
|
||
}
|
||
}
|
||
|
||
// Vulkan-Querschnitt-Cap (2D-Profil aus dem revolved profile)
|
||
function rebuildVolcanoCap() {
|
||
if (volcanoCap) {
|
||
scene.remove(volcanoCap);
|
||
volcanoCap.geometry.dispose();
|
||
volcanoCap.material.dispose();
|
||
}
|
||
if (!profilePts || profilePts.length < 2) return;
|
||
|
||
// Außenkontur des Volkano-Querschnitts: linke Seite hoch, dann rechte Seite runter
|
||
const shape = new THREE.Shape();
|
||
shape.moveTo(-profilePts[0].r, profilePts[0].y);
|
||
for (let i = 1; i < profilePts.length; i++) {
|
||
shape.lineTo(-profilePts[i].r, profilePts[i].y);
|
||
}
|
||
for (let i = profilePts.length - 1; i >= 0; i--) {
|
||
shape.lineTo(profilePts[i].r, profilePts[i].y);
|
||
}
|
||
shape.closePath();
|
||
|
||
const geom = new THREE.ShapeGeometry(shape);
|
||
// Geschichtete Gestein-Farben — wie ein echter Vulkan-Querschnitt
|
||
const colors = new Float32Array(geom.attributes.position.count * 3);
|
||
const layers = [
|
||
[0.32, 0.22, 0.12],
|
||
[0.27, 0.18, 0.10],
|
||
[0.38, 0.27, 0.16],
|
||
[0.30, 0.21, 0.13],
|
||
[0.34, 0.24, 0.14],
|
||
];
|
||
for (let i = 0; i < geom.attributes.position.count; i++) {
|
||
const y = geom.attributes.position.getY(i);
|
||
const x = geom.attributes.position.getX(i);
|
||
// Schichten alle ~5 Einheiten Höhe (gehärtete Lava-Schichten aus früheren Eruptionen)
|
||
const layerIdx = Math.floor((y + 100) / 4) % layers.length;
|
||
const lc = layers[layerIdx];
|
||
// Zentrum etwas dunkler/anders (näher am Conduit) — wird vom Conduit-Cap überdeckt
|
||
const distFromCenter = Math.abs(x);
|
||
const variance = Math.sin(y * 0.4 + x * 0.2) * 0.03;
|
||
colors[i*3] = lc[0] + variance;
|
||
colors[i*3+1] = lc[1] + variance;
|
||
colors[i*3+2] = lc[2] + variance;
|
||
}
|
||
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
|
||
volcanoCap = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({
|
||
vertexColors: true,
|
||
side: THREE.DoubleSide,
|
||
clippingPlanes: [], // gegen Clipping schützen, sonst kein Querschnitts-Bild
|
||
}));
|
||
volcanoCap.visible = crossSectionActive;
|
||
volcanoCap.renderOrder = 1; // unter Conduit-Cap, über Chamber-Cap
|
||
scene.add(volcanoCap);
|
||
}
|
||
|
||
// Wasser-Zustand: 0=aus, 1=transparent (Sockel sichtbar), 2=opak
|
||
let waterMode = 2;
|
||
|
||
function toggleWater() {
|
||
waterMode = (waterMode + 1) % 3;
|
||
const btn = document.getElementById('toggle-water');
|
||
if (crossSectionActive) {
|
||
// Im Querschnitt-Modus bleibt Wasser aus, aber Präferenz wird gemerkt
|
||
btn.classList.toggle('active', waterMode > 0);
|
||
btn.textContent = waterMode === 0 ? '💧 Aus' : (waterMode === 1 ? '💧 Sicht' : '💧 Voll');
|
||
return;
|
||
}
|
||
if (waterMode === 0) {
|
||
sea.visible = false;
|
||
btn.textContent = '💧 Aus';
|
||
btn.classList.remove('active');
|
||
} else if (waterMode === 1) {
|
||
sea.visible = true;
|
||
sea.material.uniforms.uOpacity.value = 0.50;
|
||
btn.textContent = '💧 Sicht';
|
||
btn.classList.add('active');
|
||
} else {
|
||
sea.visible = true;
|
||
sea.material.uniforms.uOpacity.value = 0.95;
|
||
btn.textContent = '💧 Voll';
|
||
btn.classList.add('active');
|
||
}
|
||
}
|
||
|
||
// Querschnitt-Toggle
|
||
function toggleCrossSection() {
|
||
crossSectionActive = !crossSectionActive;
|
||
document.getElementById('toggle-cross').classList.toggle('active', crossSectionActive);
|
||
|
||
if (crossSectionActive) {
|
||
renderer.localClippingEnabled = true;
|
||
renderer.clippingPlanes = [clipPlane];
|
||
magmaChamber.visible = true;
|
||
magmaConduit.visible = true;
|
||
if (magmaChamberCap) magmaChamberCap.visible = true;
|
||
if (magmaConduitCap) magmaConduitCap.visible = true;
|
||
if (!volcanoCap) rebuildVolcanoCap();
|
||
if (volcanoCap) volcanoCap.visible = true;
|
||
// Wasser auto-aus
|
||
sea.visible = false;
|
||
} else {
|
||
renderer.clippingPlanes = [];
|
||
magmaChamber.visible = false;
|
||
magmaConduit.visible = false;
|
||
if (magmaChamberCap) magmaChamberCap.visible = false;
|
||
if (magmaConduitCap) magmaConduitCap.visible = false;
|
||
if (volcanoCap) volcanoCap.visible = false;
|
||
// Wasser nach gespeichertem Modus wiederherstellen
|
||
if (waterMode > 0) {
|
||
sea.visible = true;
|
||
sea.material.uniforms.uOpacity.value = waterMode === 1 ? 0.50 : 0.95;
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateClipPlane() {
|
||
if (sliceMode) {
|
||
// Im Slice-Modus: feste Ausrichtung entlang Z-Achse, Slab-Position vom Slider
|
||
clipSliceFront.constant = slicePos + SLAB_THICKNESS / 2;
|
||
clipSliceBack.constant = -slicePos + SLAB_THICKNESS / 2;
|
||
return;
|
||
}
|
||
if (!crossSectionActive) return;
|
||
// Plane-Normale zeigt von der Kamera WEG durch den Ursprung.
|
||
const dir = new THREE.Vector3()
|
||
.subVectors(cam.target, camera.position)
|
||
.normalize();
|
||
clipPlane.normal.copy(dir);
|
||
clipPlane.constant = 0;
|
||
|
||
// Caps ausrichten — leicht auf der "Kept"-Seite
|
||
const offset = 0.4;
|
||
const tmp = new THREE.Vector3();
|
||
if (magmaChamberCap) {
|
||
tmp.set(0, -68, 0).addScaledVector(dir, offset);
|
||
magmaChamberCap.position.copy(tmp);
|
||
magmaChamberCap.lookAt(tmp.clone().add(dir));
|
||
}
|
||
if (magmaConduitCap) {
|
||
tmp.set(0, 0, 0).addScaledVector(dir, offset + 0.1);
|
||
magmaConduitCap.position.copy(tmp);
|
||
magmaConduitCap.lookAt(tmp.clone().add(dir));
|
||
}
|
||
if (volcanoCap) {
|
||
tmp.set(0, 0, 0).addScaledVector(dir, offset - 0.05);
|
||
volcanoCap.position.copy(tmp);
|
||
volcanoCap.lookAt(tmp.clone().add(dir));
|
||
}
|
||
|
||
// "Brodeln"
|
||
const t = performance.now() / 1000;
|
||
const pulse = 0.85 + 0.15 * (0.5 + 0.5 * Math.sin(t * 1.8));
|
||
if (magmaChamberCap) magmaChamberCap.material.color.setRGB(pulse, pulse * 0.9, pulse * 0.85);
|
||
if (magmaConduitCap) magmaConduitCap.material.color.setRGB(pulse, pulse * 0.95, pulse * 0.9);
|
||
}
|
||
|
||
function enterSliceMode() {
|
||
sliceMode = true;
|
||
// Slab-Clipping (alte Methode bleibt im Code, aber das visuell relevante ist jetzt das Side-Panel)
|
||
// Wir tun NICHTS mit der Kamera und Clipping — nur das 2D-Panel öffnen
|
||
const panel = document.getElementById('cs-panel');
|
||
if (panel) panel.style.display = 'block';
|
||
// Slider behalten für ggf. zukünftige Position-Variation (z.B. wenn der Schnitt verschiebbar wird)
|
||
// Vorerst slider verstecken
|
||
const ctrl = document.getElementById('slice-controls');
|
||
if (ctrl) ctrl.style.display = 'none';
|
||
}
|
||
|
||
function exitSliceMode() {
|
||
sliceMode = false;
|
||
const panel = document.getElementById('cs-panel');
|
||
if (panel) panel.style.display = 'none';
|
||
const ctrl = document.getElementById('slice-controls');
|
||
if (ctrl) ctrl.style.display = 'none';
|
||
}
|
||
|
||
// Zeichnet das geologische Querschnittsbild ins Canvas-2D-Panel.
|
||
// Echte gefüllte Flächen statt Mesh-Clipping-Konturen.
|
||
function drawCrossSection() {
|
||
const canvas = document.getElementById('cs-canvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const W = canvas.width, H = canvas.height;
|
||
|
||
// Welt-Range im Canvas:
|
||
// X: -200 bis +200 (volle Inselbreite)
|
||
// Y: -110 (Magmakammer unten) bis +90 (Vulkangipfel oben)
|
||
const xMin = -200, xMax = 200;
|
||
const yMin = -110, yMax = 90;
|
||
const scale = Math.min(W / (xMax - xMin), H / (yMax - yMin));
|
||
const ox = W / 2;
|
||
const oy = H - (-yMin) * scale;
|
||
const wx = x => ox + x * scale;
|
||
const wy = y => oy - y * scale;
|
||
|
||
// 1. Himmel
|
||
const seaY = -1;
|
||
const seaPxY = wy(seaY);
|
||
const skyGrad = ctx.createLinearGradient(0, 0, 0, seaPxY);
|
||
skyGrad.addColorStop(0, '#b8d8e8');
|
||
skyGrad.addColorStop(1, '#e0f0f8');
|
||
ctx.fillStyle = skyGrad;
|
||
ctx.fillRect(0, 0, W, seaPxY);
|
||
|
||
// 2. Meer (alles unter Meeresspiegel)
|
||
const seaGrad = ctx.createLinearGradient(0, seaPxY, 0, H);
|
||
seaGrad.addColorStop(0, '#5ba1c9');
|
||
seaGrad.addColorStop(0.4, '#3d83ab');
|
||
seaGrad.addColorStop(1, '#1e4866');
|
||
ctx.fillStyle = seaGrad;
|
||
ctx.fillRect(0, seaPxY, W, H - seaPxY);
|
||
|
||
// 3. Geländeprofil entlang z=0 sampeln
|
||
const tSamples = [];
|
||
for (let x = xMin; x <= xMax; x += 2) {
|
||
tSamples.push({ x, y: terrainHeightAt(x, 0) });
|
||
}
|
||
|
||
// 4. Erdkruste + Untergrund (gefüllt von Terrainoberkante bis Canvas-Boden)
|
||
ctx.beginPath();
|
||
ctx.moveTo(wx(xMin), wy(tSamples[0].y));
|
||
for (const p of tSamples) ctx.lineTo(wx(p.x), wy(p.y));
|
||
ctx.lineTo(wx(xMax), H);
|
||
ctx.lineTo(wx(xMin), H);
|
||
ctx.closePath();
|
||
const earthGrad = ctx.createLinearGradient(0, wy(0), 0, H);
|
||
earthGrad.addColorStop(0, '#7a5530');
|
||
earthGrad.addColorStop(0.4, '#5a3e22');
|
||
earthGrad.addColorStop(1, '#2c1e10');
|
||
ctx.fillStyle = earthGrad;
|
||
ctx.fill();
|
||
|
||
// 5. Geologische Schichten (subtile horizontale Linien im Untergrund)
|
||
ctx.strokeStyle = 'rgba(30, 18, 8, 0.25)';
|
||
ctx.lineWidth = 1;
|
||
for (let y = -8; y > -110; y -= 6) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, wy(y));
|
||
ctx.lineTo(W, wy(y));
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 6. Vulkankegel (gefüllt aus profilePts, gespiegelt)
|
||
if (profilePts && profilePts.length > 0) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(wx(-profilePts[0].r), wy(0));
|
||
for (let i = 0; i < profilePts.length; i++) ctx.lineTo(wx(-profilePts[i].r), wy(profilePts[i].y));
|
||
for (let i = profilePts.length - 1; i >= 0; i--) ctx.lineTo(wx(profilePts[i].r), wy(profilePts[i].y));
|
||
ctx.closePath();
|
||
const volcGrad = ctx.createLinearGradient(0, wy(maxHeight()), 0, wy(0));
|
||
volcGrad.addColorStop(0, '#8e6230');
|
||
volcGrad.addColorStop(1, '#5a3e22');
|
||
ctx.fillStyle = volcGrad;
|
||
ctx.fill();
|
||
// Schichtungs-Bänder im Vulkan (vergangene Eruptions-Lavaschichten)
|
||
ctx.strokeStyle = 'rgba(40, 25, 12, 0.35)';
|
||
ctx.lineWidth = 1;
|
||
const top = maxHeight();
|
||
for (let y = 0; y < top; y += 4) {
|
||
const t = Math.min(0.99, y / top);
|
||
// Profil-Radius an dieser Höhe (linear zwischen Punkten)
|
||
const idx = Math.floor(t * (profilePts.length - 1));
|
||
const r = profilePts[idx].r;
|
||
ctx.beginPath();
|
||
ctx.moveTo(wx(-r), wy(y));
|
||
ctx.lineTo(wx(r), wy(y));
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// 7. Magmakammer (unregelmäßiger Blob)
|
||
const chamberY = -68;
|
||
ctx.beginPath();
|
||
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.08) {
|
||
const noise =
|
||
Math.sin(a*4) * 4 +
|
||
Math.cos(a*3 + 0.5) * 5 +
|
||
Math.sin(a*5 + 1.2) * 3;
|
||
const baseR = 38;
|
||
const r = baseR + noise * 0.6;
|
||
const x = Math.cos(a) * r;
|
||
const y = chamberY + Math.sin(a) * r * 0.88;
|
||
if (a === 0) ctx.moveTo(wx(x), wy(y));
|
||
else ctx.lineTo(wx(x), wy(y));
|
||
}
|
||
ctx.closePath();
|
||
const chGrad = ctx.createRadialGradient(wx(0), wy(chamberY - 5), 0, wx(0), wy(chamberY), 40 * scale);
|
||
// Brodeln per Zeit
|
||
const pulse = 0.85 + 0.15 * (0.5 + 0.5 * Math.sin(performance.now() * 0.0018));
|
||
chGrad.addColorStop(0, `rgb(${255*pulse|0}, ${190*pulse|0}, ${70*pulse|0})`);
|
||
chGrad.addColorStop(0.5, `rgb(${255*pulse|0}, ${110*pulse|0}, ${40*pulse|0})`);
|
||
chGrad.addColorStop(1, '#a83020');
|
||
ctx.fillStyle = chGrad;
|
||
ctx.fill();
|
||
// Kontur dunkelrot
|
||
ctx.strokeStyle = '#5a1a08';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
|
||
// 8. Conduit (Trapez)
|
||
if (profilePts && profilePts.length > 0) {
|
||
const conduitTop = maxHeight() + 2;
|
||
const conduitBottom = -36;
|
||
ctx.beginPath();
|
||
ctx.moveTo(wx(-7), wy(conduitBottom));
|
||
ctx.lineTo(wx(7), wy(conduitBottom));
|
||
ctx.lineTo(wx(3.5), wy(conduitTop));
|
||
ctx.lineTo(wx(-3.5), wy(conduitTop));
|
||
ctx.closePath();
|
||
const cdGrad = ctx.createLinearGradient(0, wy(conduitBottom), 0, wy(conduitTop));
|
||
cdGrad.addColorStop(0, `rgb(${200*pulse|0}, ${60*pulse|0}, ${20*pulse|0})`);
|
||
cdGrad.addColorStop(0.5, `rgb(${255*pulse|0}, ${130*pulse|0}, ${50*pulse|0})`);
|
||
cdGrad.addColorStop(1, `rgb(${255*pulse|0}, ${190*pulse|0}, ${80*pulse|0})`);
|
||
ctx.fillStyle = cdGrad;
|
||
ctx.fill();
|
||
}
|
||
|
||
// 9. Meeresspiegel-Linie
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.45)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, seaPxY);
|
||
ctx.lineTo(W, seaPxY);
|
||
ctx.stroke();
|
||
|
||
// 10. Während aktiver Eruption: Lava im Conduit visualisieren
|
||
if (erupt.active && profilePts) {
|
||
const expl = explosivity();
|
||
// Eruptionssäule (Asche/Dampf) über dem Krater
|
||
const colTop = maxHeight() + 30 + expl * 25;
|
||
ctx.fillStyle = `rgba(180, 175, 170, ${0.45 + expl*0.3})`;
|
||
ctx.beginPath();
|
||
const colW = 8 + expl * 6;
|
||
ctx.moveTo(wx(-3), wy(maxHeight()));
|
||
ctx.lineTo(wx(-colW), wy(colTop));
|
||
ctx.lineTo(wx(colW), wy(colTop));
|
||
ctx.lineTo(wx(3), wy(maxHeight()));
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
function emitMagmaBubble() {
|
||
// Kleine Sprite-Bubble, die durch den Conduit aufsteigt
|
||
if (magmaBubbles.length > 10) return;
|
||
const chamberTopY = -30;
|
||
const top = maxHeight();
|
||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||
map: TEX_LAVA(),
|
||
color: 0xffaa44,
|
||
transparent: true,
|
||
opacity: 0.9,
|
||
depthWrite: false,
|
||
}));
|
||
const startY = chamberTopY - 4 + Math.random() * 5;
|
||
sprite.position.set((Math.random()-0.5) * 4, startY, (Math.random()-0.5) * 1);
|
||
sprite.scale.set(2.2, 2.2, 1);
|
||
sprite.renderOrder = 4; // über Conduit-Cap
|
||
scene.add(sprite);
|
||
magmaBubbles.push({
|
||
sprite,
|
||
targetY: top + 4 + Math.random() * 2,
|
||
startY,
|
||
age: 0,
|
||
lifetime: 3.5 + Math.random() * 2,
|
||
});
|
||
}
|
||
|
||
function updateMagmaBubbles(dt) {
|
||
for (let i = magmaBubbles.length - 1; i >= 0; i--) {
|
||
const b = magmaBubbles[i];
|
||
b.age += dt;
|
||
const t = b.age / b.lifetime;
|
||
if (t >= 1 || !crossSectionActive) {
|
||
scene.remove(b.sprite);
|
||
b.sprite.material.dispose();
|
||
magmaBubbles.splice(i, 1);
|
||
continue;
|
||
}
|
||
// langsamer Aufstieg mit leichtem Wackeln
|
||
b.sprite.position.y = b.startY + (b.targetY - b.startY) * t;
|
||
b.sprite.position.x = (Math.random()-0.5) * 0.6 + Math.sin(b.age * 4 + i) * 1.5;
|
||
b.sprite.material.opacity = Math.min(1, (1 - t) * 1.5) * 0.85;
|
||
const sz = 2.2 + t * 1.5;
|
||
b.sprite.scale.set(sz, sz, 1);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// HIMMEL, SONNE, BODEN
|
||
// ============================================================================
|
||
function createSky() {
|
||
const geom = new THREE.SphereGeometry(1200, 32, 24);
|
||
const mat = new THREE.ShaderMaterial({
|
||
side: THREE.BackSide,
|
||
depthWrite: false,
|
||
uniforms: {
|
||
top: { value: new THREE.Color(0x2a5a90) },
|
||
mid: { value: new THREE.Color(0x6fa6d2) },
|
||
bottom: { value: new THREE.Color(0xd8e8f0) },
|
||
},
|
||
vertexShader: `varying vec3 vW; void main(){vW=(modelMatrix*vec4(position,1.0)).xyz;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
|
||
fragmentShader: `uniform vec3 top; uniform vec3 mid; uniform vec3 bottom; varying vec3 vW;
|
||
void main(){ float h=normalize(vW).y; vec3 c=h>0.0?mix(mid,top,smoothstep(0.0,0.7,h)):mix(mid,bottom,smoothstep(0.0,-0.15,h)); gl_FragColor=vec4(c,1.0);}`,
|
||
});
|
||
sky = new THREE.Mesh(geom, mat);
|
||
scene.add(sky);
|
||
}
|
||
|
||
function createSun() {
|
||
const s = new THREE.Sprite(new THREE.SpriteMaterial({
|
||
map: TEX_SUN(), transparent: true, depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
}));
|
||
s.position.set(450, 380, 280);
|
||
s.scale.set(140, 140, 1);
|
||
scene.add(s);
|
||
}
|
||
|
||
function createSea() {
|
||
const geom = new THREE.PlaneGeometry(1600, 1600, 90, 90);
|
||
geom.rotateX(-Math.PI/2);
|
||
const mat = new THREE.ShaderMaterial({
|
||
uniforms: {
|
||
time: { value: 0 },
|
||
deep: { value: new THREE.Color(0x0e2a4c) },
|
||
shallow: { value: new THREE.Color(0x3d83ab) },
|
||
uOpacity: { value: 0.95 },
|
||
},
|
||
vertexShader: `
|
||
uniform float time;
|
||
varying vec3 vWorld;
|
||
varying float vWave;
|
||
void main() {
|
||
vec3 p = position;
|
||
// Sanftere Wellen, max-Amplitude ~0.8 (war ~1.45)
|
||
float w = sin(p.x * 0.045 + time) * 0.30
|
||
+ sin(p.z * 0.055 + time * 1.3) * 0.30
|
||
+ sin((p.x + p.z) * 0.035 - time * 0.7) * 0.20;
|
||
p.y += w;
|
||
vWorld = p; vWave = w;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||
}
|
||
`,
|
||
fragmentShader: `
|
||
uniform vec3 deep;
|
||
uniform vec3 shallow;
|
||
uniform float uOpacity;
|
||
varying vec3 vWorld;
|
||
varying float vWave;
|
||
void main() {
|
||
float d = length(vWorld.xz);
|
||
float t = clamp((d - 80.0) / 400.0, 0.0, 1.0);
|
||
vec3 col = mix(shallow, deep, t);
|
||
col += vWave * 0.06;
|
||
// Schaumkronen schwächer bei Transparenz
|
||
col += smoothstep(0.55, 0.75, vWave) * vec3(0.45) * uOpacity;
|
||
gl_FragColor = vec4(col, uOpacity);
|
||
}
|
||
`,
|
||
transparent: true,
|
||
});
|
||
sea = new THREE.Mesh(geom, mat);
|
||
sea.position.y = -1.0; // Meeresspiegel niedriger → Insel wird sichtbar
|
||
scene.add(sea);
|
||
}
|
||
|
||
// ============================================================================
|
||
// TERRAIN (Höhenfeld-Mesh — sammelt erstarrte Lava-Ablagerungen)
|
||
// ============================================================================
|
||
function createTerrain() {
|
||
const geom = new THREE.PlaneGeometry(TSIZE, TSIZE, TGRID-1, TGRID-1);
|
||
geom.rotateX(-Math.PI/2);
|
||
|
||
const pos = geom.attributes.position;
|
||
const count = pos.count;
|
||
|
||
for (let i = 0; i < count; i++) pos.setY(i, TBASE_Y);
|
||
|
||
const colors = new Float32Array(count * 3);
|
||
for (let i = 0; i < count; i++) {
|
||
colors[i*3] = 0.12; colors[i*3+1] = 0.18; colors[i*3+2] = 0.28;
|
||
}
|
||
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
|
||
terrainAge = new Float32Array(count);
|
||
terrainHeight = new Float32Array(count);
|
||
terrainDistFromCrater = new Float32Array(count);
|
||
terrainHeight.fill(TBASE_Y);
|
||
|
||
const half = TSIZE / 2;
|
||
for (let i = 0; i < count; i++) {
|
||
const c = i % TGRID;
|
||
const r = Math.floor(i / TGRID);
|
||
const x = (c / (TGRID - 1)) * TSIZE - half;
|
||
const z = (r / (TGRID - 1)) * TSIZE - half;
|
||
terrainDistFromCrater[i] = Math.hypot(x, z);
|
||
}
|
||
|
||
geom.computeVertexNormals();
|
||
|
||
terrain = new THREE.Mesh(geom, new THREE.MeshStandardMaterial({
|
||
vertexColors: true, roughness: 0.92, metalness: 0.02,
|
||
}));
|
||
scene.add(terrain);
|
||
}
|
||
|
||
function terrainHeightAt(x, z) {
|
||
const half = TSIZE / 2;
|
||
const fx = (x + half) / TSIZE * (TGRID - 1);
|
||
const fz = (z + half) / TSIZE * (TGRID - 1);
|
||
if (fx < 0 || fx >= TGRID-1 || fz < 0 || fz >= TGRID-1) return TBASE_Y;
|
||
const c0 = Math.floor(fx), r0 = Math.floor(fz);
|
||
const tx = fx - c0, tz = fz - r0;
|
||
const h00 = terrainHeight[r0 * TGRID + c0];
|
||
const h10 = terrainHeight[r0 * TGRID + c0 + 1];
|
||
const h01 = terrainHeight[(r0+1) * TGRID + c0];
|
||
const h11 = terrainHeight[(r0+1) * TGRID + c0 + 1];
|
||
return h00*(1-tx)*(1-tz) + h10*tx*(1-tz) + h01*(1-tx)*tz + h11*tx*tz;
|
||
}
|
||
|
||
// Maximale Terrain-Höhe abhängig vom Abstand zum Vulkan.
|
||
// Lava kann nicht höher landen als der Krater — sie fließt vom Krater abwärts.
|
||
// Innerhalb der Vulkanbasis: kein Terrain. Außerhalb: gedämpfter Aufstieg
|
||
// bis maximal 55% der Vulkanhöhe weit draußen.
|
||
function maxAllowedTerrainHeight(x, z) {
|
||
const r = Math.hypot(x, z);
|
||
const base = baseRadius();
|
||
const maxH = maxHeight();
|
||
if (r < base * 0.92) return TBASE_Y; // unter dem Vulkanmesh
|
||
const excess = r - base;
|
||
return maxH * 0.7 * (1 - Math.exp(-excess / 65));
|
||
}
|
||
|
||
function depositLava(x, z, mass) {
|
||
const half = TSIZE / 2;
|
||
const radius = 22;
|
||
const cellSize = TSIZE / (TGRID - 1);
|
||
const cellRadius = Math.ceil(radius / cellSize);
|
||
const cCenter = Math.round((x + half) / cellSize);
|
||
const rCenter = Math.round((z + half) / cellSize);
|
||
const sigma = radius / 2.2;
|
||
const peak = 0.075 * mass;
|
||
|
||
const pos = terrain.geometry.attributes.position;
|
||
for (let r = Math.max(0, rCenter - cellRadius); r <= Math.min(TGRID-1, rCenter + cellRadius); r++) {
|
||
for (let c = Math.max(0, cCenter - cellRadius); c <= Math.min(TGRID-1, cCenter + cellRadius); c++) {
|
||
const i = r * TGRID + c;
|
||
const vx = c * cellSize - half;
|
||
const vz = r * cellSize - half;
|
||
const d = Math.hypot(vx - x, vz - z);
|
||
if (d > radius) continue;
|
||
const bump = peak * Math.exp(-(d*d) / (2 * sigma * sigma));
|
||
const cap = maxAllowedTerrainHeight(vx, vz);
|
||
terrainHeight[i] = Math.min(terrainHeight[i] + bump, cap);
|
||
pos.setY(i, terrainHeight[i]);
|
||
terrainAge[i] = 0;
|
||
}
|
||
}
|
||
terrainDirty = true;
|
||
burnAt(x, z, 9);
|
||
}
|
||
|
||
function globalAshfall() {
|
||
const expl = explosivity();
|
||
const cellSize = TSIZE / (TGRID - 1);
|
||
const half = TSIZE / 2;
|
||
const peakR = baseRadius() + 50;
|
||
const maxR = baseRadius() + 230;
|
||
const ashAmt = 0.55 + expl * 1.0;
|
||
const pos = terrain.geometry.attributes.position;
|
||
|
||
for (let r = 0; r < TGRID; r++) {
|
||
for (let c = 0; c < TGRID; c++) {
|
||
const vx = c * cellSize - half;
|
||
const vz = r * cellSize - half;
|
||
const d = Math.hypot(vx, vz);
|
||
if (d > maxR) continue;
|
||
if (d < baseRadius() * 0.92) continue; // unter dem Vulkan kein Aschebump
|
||
let factor;
|
||
if (d < peakR) factor = d / peakR;
|
||
else factor = Math.max(0, 1 - (d - peakR) / (maxR - peakR));
|
||
const bump = ashAmt * factor * (0.7 + Math.random() * 0.5);
|
||
const i = r * TGRID + c;
|
||
const cap = maxAllowedTerrainHeight(vx, vz);
|
||
terrainHeight[i] = Math.min(terrainHeight[i] + bump, cap);
|
||
pos.setY(i, terrainHeight[i]);
|
||
}
|
||
}
|
||
terrainDirty = true;
|
||
}
|
||
|
||
// Wird aufgerufen wenn der Vulkan wächst — beschneidet existierende Terrain-Höhen
|
||
// die jetzt durch den größeren Vulkan-Mesh "hindurchstechen" oder zu hoch wirken.
|
||
function clampTerrainToVolcano() {
|
||
const cellSize = TSIZE / (TGRID - 1);
|
||
const half = TSIZE / 2;
|
||
const pos = terrain.geometry.attributes.position;
|
||
let dirty = false;
|
||
for (let r = 0; r < TGRID; r++) {
|
||
for (let c = 0; c < TGRID; c++) {
|
||
const i = r * TGRID + c;
|
||
const vx = c * cellSize - half;
|
||
const vz = r * cellSize - half;
|
||
const cap = maxAllowedTerrainHeight(vx, vz);
|
||
if (terrainHeight[i] > cap) {
|
||
terrainHeight[i] = cap;
|
||
pos.setY(i, cap);
|
||
dirty = true;
|
||
}
|
||
}
|
||
}
|
||
if (dirty) terrainDirty = true;
|
||
}
|
||
|
||
// Reaktives Vulkanwachstum: der Vulkan passt sich an die maximale Terrainhöhe
|
||
// der Umgebung an. Geologisch: der Kegel baut sich aus dem deponierten Material
|
||
// auf — Lavaströme und Aschefall lassen Land entstehen, der Vulkan wächst diesem
|
||
// Niveau hinterher und bleibt immer der höchste Punkt.
|
||
function adaptVolcanoToTerrain() {
|
||
if (!terrain) return;
|
||
let maxAround = 0;
|
||
const innerR = baseRadius() * 0.95;
|
||
const outerR = baseRadius() * 2.5;
|
||
const angles = 48;
|
||
for (let s = 0; s < angles; s++) {
|
||
const a = (s / angles) * Math.PI * 2;
|
||
for (let r = innerR; r < outerR; r += 8) {
|
||
const x = Math.cos(a) * r;
|
||
const z = Math.sin(a) * r;
|
||
const h = terrainHeightAt(x, z);
|
||
if (h > maxAround) maxAround = h;
|
||
}
|
||
}
|
||
// Vulkan-Gipfel soll deutlich über dem Umgebungs-Maximum liegen
|
||
const desiredHeight = Math.max(maxHeight(), maxAround * 1.7 + 2);
|
||
// Aus gewünschter Höhe → volcanoGrowth zurückrechnen
|
||
// height = (30 + type*45) * (0.18 + growth*0.82)
|
||
const denom = 30 + params.type * 45;
|
||
const desiredScale = desiredHeight / denom;
|
||
const newGrowth = Math.max(0, (desiredScale - 0.18) / 0.82);
|
||
volcanoGrowth = Math.min(1, Math.max(volcanoGrowth, newGrowth));
|
||
}
|
||
|
||
// Bäume und Häuser nach Eruption an aktuelles Terrain-Niveau anheben
|
||
// (sonst stehen sie nach Aschefall halb im Boden)
|
||
function reseatVegetation() {
|
||
const tmpM = new THREE.Matrix4();
|
||
const tmpP = new THREE.Vector3();
|
||
const tmpQ = new THREE.Quaternion();
|
||
const tmpS = new THREE.Vector3();
|
||
const Y_AXIS = new THREE.Vector3(0, 1, 0);
|
||
|
||
for (let i = 0; i < treeData.length; i++) {
|
||
const d = treeData[i];
|
||
if (!d.alive || d.burning) continue;
|
||
const y = terrainHeightAt(d.x, d.z);
|
||
tmpQ.setFromAxisAngle(Y_AXIS, d.rotY);
|
||
tmpP.set(d.x, y, d.z);
|
||
tmpS.set(d.scale, d.scale, d.scale);
|
||
tmpM.compose(tmpP, tmpQ, tmpS);
|
||
trees.setMatrixAt(i, tmpM);
|
||
}
|
||
trees.instanceMatrix.needsUpdate = true;
|
||
|
||
for (let i = 0; i < houseData.length; i++) {
|
||
const d = houseData[i];
|
||
if (!d.alive || d.burning) continue;
|
||
const y = terrainHeightAt(d.x, d.z);
|
||
tmpQ.setFromAxisAngle(Y_AXIS, d.rotY);
|
||
tmpP.set(d.x, y, d.z);
|
||
tmpS.set(d.scale, d.scale, d.scale);
|
||
tmpM.compose(tmpP, tmpQ, tmpS);
|
||
houses.setMatrixAt(i, tmpM);
|
||
}
|
||
houses.instanceMatrix.needsUpdate = true;
|
||
|
||
for (let i = 0; i < churchData.length; i++) {
|
||
const d = churchData[i];
|
||
if (!d.alive || d.burning) continue;
|
||
const y = terrainHeightAt(d.x, d.z);
|
||
tmpQ.setFromAxisAngle(Y_AXIS, d.rotY);
|
||
tmpP.set(d.x, y, d.z);
|
||
tmpS.set(d.scale, d.scale, d.scale);
|
||
tmpM.compose(tmpP, tmpQ, tmpS);
|
||
churches.setMatrixAt(i, tmpM);
|
||
}
|
||
churches.instanceMatrix.needsUpdate = true;
|
||
}
|
||
|
||
function updateTerrain(dt) {
|
||
if (!terrain) return;
|
||
const colors = terrain.geometry.attributes.color;
|
||
const colArr = colors.array;
|
||
|
||
for (let i = 0; i < terrainHeight.length; i++) {
|
||
const y = terrainHeight[i];
|
||
if (y < TBASE_Y + 0.05) continue;
|
||
terrainAge[i] += dt;
|
||
const age = terrainAge[i];
|
||
const dist = terrainDistFromCrater[i];
|
||
|
||
let cr, cg, cb;
|
||
if (y < 0) {
|
||
cr = 0.18; cg = 0.16; cb = 0.18;
|
||
} else if (age < 4) {
|
||
const t = age / 4;
|
||
cr = 1.0 - t * 0.3;
|
||
cg = 0.45 - t * 0.30;
|
||
cb = 0.08;
|
||
} else if (age < 25) {
|
||
const t = (age - 4) / 21;
|
||
cr = 0.70 * (1-t) + 0.08 * t;
|
||
cg = 0.15 * (1-t) + 0.07 * t;
|
||
cb = 0.05 * (1-t) + 0.07 * t;
|
||
} else if (age < 80) {
|
||
const t = (age - 25) / 55;
|
||
cr = 0.08 + t * 0.30;
|
||
cg = 0.07 + t * 0.22;
|
||
cb = 0.07 + t * 0.10;
|
||
} else {
|
||
const t = Math.min(1, (age - 80) / 50);
|
||
const greenness = Math.max(0, Math.min(1, (dist - 80) / 60)) * t;
|
||
const brown = [0.38, 0.29, 0.17];
|
||
const green = [0.20, 0.48, 0.16];
|
||
cr = brown[0] * (1 - greenness) + green[0] * greenness;
|
||
cg = brown[1] * (1 - greenness) + green[1] * greenness;
|
||
cb = brown[2] * (1 - greenness) + green[2] * greenness;
|
||
}
|
||
colArr[i*3] = cr;
|
||
colArr[i*3 + 1] = cg;
|
||
colArr[i*3 + 2] = cb;
|
||
}
|
||
colors.needsUpdate = true;
|
||
|
||
if (terrainDirty) {
|
||
terrain.geometry.attributes.position.needsUpdate = true;
|
||
terrain.geometry.computeVertexNormals();
|
||
terrainDirty = false;
|
||
}
|
||
}
|
||
|
||
function isVegetated(x, z) {
|
||
const h = terrainHeightAt(x, z);
|
||
// Wellenpeak liegt bei sea(-1.0) + amplitude(+0.8) = -0.2 in Weltkoordinaten.
|
||
// Vegetation nur, wenn Terrain klar darüber → Bäume stehen nicht im Wasser.
|
||
if (h < 0.6) return false;
|
||
const half = TSIZE / 2;
|
||
const c = Math.round((x + half) / TSIZE * (TGRID - 1));
|
||
const r = Math.round((z + half) / TSIZE * (TGRID - 1));
|
||
if (c < 0 || c >= TGRID || r < 0 || r >= TGRID) return false;
|
||
const i = r * TGRID + c;
|
||
if (terrainAge[i] < 40) return false;
|
||
// Sicherheitsabstand wächst mit dem Vulkan
|
||
if (terrainDistFromCrater[i] < baseRadius() * 1.3 + 25) return false;
|
||
return true;
|
||
}
|
||
|
||
// ============================================================================
|
||
// VEGETATION & SIEDLUNG (InstancedMesh)
|
||
// ============================================================================
|
||
function mergeSimple(geoms, hexColors) {
|
||
let posCount = 0, idxCount = 0;
|
||
geoms.forEach(g => {
|
||
posCount += g.attributes.position.count;
|
||
if (g.index) idxCount += g.index.count;
|
||
else idxCount += g.attributes.position.count;
|
||
});
|
||
const positions = new Float32Array(posCount * 3);
|
||
const normals = new Float32Array(posCount * 3);
|
||
const colors = new Float32Array(posCount * 3);
|
||
const indices = new Uint32Array(idxCount);
|
||
let pOff = 0, iOff = 0, vBase = 0;
|
||
geoms.forEach((g, gi) => {
|
||
g.computeVertexNormals();
|
||
const p = g.attributes.position.array;
|
||
const n = g.attributes.normal.array;
|
||
positions.set(p, pOff * 3);
|
||
normals.set(n, pOff * 3);
|
||
const col = new THREE.Color(hexColors[gi]);
|
||
for (let v = 0; v < g.attributes.position.count; v++) {
|
||
colors[(pOff + v)*3] = col.r;
|
||
colors[(pOff + v)*3+1] = col.g;
|
||
colors[(pOff + v)*3+2] = col.b;
|
||
}
|
||
if (g.index) {
|
||
const ix = g.index.array;
|
||
for (let k = 0; k < ix.length; k++) indices[iOff + k] = ix[k] + vBase;
|
||
iOff += ix.length;
|
||
} else {
|
||
for (let k = 0; k < g.attributes.position.count; k++) indices[iOff + k] = vBase + k;
|
||
iOff += g.attributes.position.count;
|
||
}
|
||
pOff += g.attributes.position.count;
|
||
vBase += g.attributes.position.count;
|
||
});
|
||
const merged = new THREE.BufferGeometry();
|
||
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||
merged.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
||
return merged;
|
||
}
|
||
|
||
function createTrees() {
|
||
const trunkGeom = new THREE.CylinderGeometry(0.4, 0.5, 2, 5);
|
||
trunkGeom.translate(0, 1, 0);
|
||
const leavesGeom = new THREE.ConeGeometry(2.2, 6, 6);
|
||
leavesGeom.translate(0, 4.5, 0);
|
||
const treeGeom = mergeSimple([trunkGeom, leavesGeom], [0x6b3f1d, 0x2d6020]);
|
||
trees = new THREE.InstancedMesh(treeGeom, new THREE.MeshStandardMaterial({
|
||
vertexColors: true, roughness: 0.85,
|
||
}), MAX_TREES);
|
||
trees.count = 0;
|
||
for (let i = 0; i < MAX_TREES; i++) treeData.push({ alive: false, x: 0, z: 0 });
|
||
scene.add(trees);
|
||
}
|
||
|
||
function createHouses() {
|
||
const bodyGeom = new THREE.BoxGeometry(4, 3, 5);
|
||
bodyGeom.translate(0, 1.5, 0);
|
||
const roofGeom = new THREE.ConeGeometry(3.5, 2.5, 4);
|
||
roofGeom.rotateY(Math.PI / 4);
|
||
roofGeom.translate(0, 4.25, 0);
|
||
const houseGeom = mergeSimple([bodyGeom, roofGeom], [0xd6c089, 0x8a3a25]);
|
||
houses = new THREE.InstancedMesh(houseGeom, new THREE.MeshStandardMaterial({
|
||
vertexColors: true, roughness: 0.8,
|
||
}), MAX_HOUSES);
|
||
houses.count = 0;
|
||
for (let i = 0; i < MAX_HOUSES; i++) houseData.push({ alive: false, x: 0, z: 0 });
|
||
scene.add(houses);
|
||
}
|
||
|
||
function createChurches() {
|
||
// Kirchenkörper: höheres Schiff (Box) + spitzer Turm + Kreuz oben
|
||
const navGeom = new THREE.BoxGeometry(4, 6, 5);
|
||
navGeom.translate(0, 3, 0);
|
||
const towerGeom = new THREE.BoxGeometry(2, 7, 2);
|
||
towerGeom.translate(0, 3.5, -2);
|
||
const spireGeom = new THREE.ConeGeometry(1.5, 4, 4);
|
||
spireGeom.rotateY(Math.PI / 4);
|
||
spireGeom.translate(0, 9, -2);
|
||
// Kreuz (T-Form)
|
||
const crossVerticalGeom = new THREE.BoxGeometry(0.25, 1.6, 0.25);
|
||
crossVerticalGeom.translate(0, 11.8, -2);
|
||
const crossHorizontalGeom = new THREE.BoxGeometry(1.1, 0.25, 0.25);
|
||
crossHorizontalGeom.translate(0, 11.5, -2);
|
||
|
||
const churchGeom = mergeSimple(
|
||
[navGeom, towerGeom, spireGeom, crossVerticalGeom, crossHorizontalGeom],
|
||
[0xe8e0d0, 0xd6c8b0, 0x6a3a20, 0xb09060, 0xb09060]
|
||
);
|
||
churches = new THREE.InstancedMesh(churchGeom, new THREE.MeshStandardMaterial({
|
||
vertexColors: true, roughness: 0.75,
|
||
}), MAX_CHURCHES);
|
||
churches.count = 0;
|
||
for (let i = 0; i < MAX_CHURCHES; i++) churchData.push({ alive: false, x: 0, z: 0 });
|
||
scene.add(churches);
|
||
}
|
||
|
||
function spawnChurch(x, z) {
|
||
const i = findFreeSlot(churchData);
|
||
if (i < 0) return false;
|
||
const y = terrainHeightAt(x, z);
|
||
const m = new THREE.Matrix4();
|
||
const rotY = Math.random() * Math.PI * 2;
|
||
m.compose(new THREE.Vector3(x, y, z),
|
||
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), rotY),
|
||
new THREE.Vector3(1, 1, 1));
|
||
churches.setMatrixAt(i, m);
|
||
churchData[i].alive = true;
|
||
churchData[i].x = x; churchData[i].z = z;
|
||
churchData[i].scale = 1; churchData[i].rotY = rotY;
|
||
churchData[i].burning = false; churchData[i].burnAge = 0; churchData[i].skullDone = false;
|
||
churches.setColorAt(i, new THREE.Color(1, 1, 1));
|
||
if (churches.instanceColor) churches.instanceColor.needsUpdate = true;
|
||
if (i >= churches.count) churches.count = i + 1;
|
||
churches.instanceMatrix.needsUpdate = true;
|
||
return true;
|
||
}
|
||
|
||
// Wenn 4+ Häuser nah beieinander stehen, wird im Cluster-Zentrum eine Kirche gebaut
|
||
function checkChurchCluster(nx, nz) {
|
||
const clusterR = 55;
|
||
const r2 = clusterR * clusterR;
|
||
let count = 0, sx = 0, sz = 0;
|
||
for (let i = 0; i < houseData.length; i++) {
|
||
if (!houseData[i].alive) continue;
|
||
const dx = houseData[i].x - nx, dz = houseData[i].z - nz;
|
||
if (dx*dx + dz*dz < r2) {
|
||
count++;
|
||
sx += houseData[i].x;
|
||
sz += houseData[i].z;
|
||
}
|
||
}
|
||
if (count < 4) return;
|
||
const cx = sx / count, cz = sz / count;
|
||
// Schon eine Kirche in der Nähe?
|
||
for (let i = 0; i < churchData.length; i++) {
|
||
if (!churchData[i].alive) continue;
|
||
const dx = churchData[i].x - cx, dz = churchData[i].z - cz;
|
||
if (dx*dx + dz*dz < 90*90) return;
|
||
}
|
||
// Höhe muss passen + nicht zu nah am Vulkan
|
||
if (!isVegetated(cx, cz)) return;
|
||
spawnChurch(cx, cz);
|
||
}
|
||
|
||
function spawnTree(x, z) {
|
||
const i = findFreeSlot(treeData);
|
||
if (i < 0) return false;
|
||
const y = terrainHeightAt(x, z);
|
||
const m = new THREE.Matrix4();
|
||
const scale = 0.7 + Math.random() * 0.6;
|
||
const rotY = Math.random()*Math.PI*2;
|
||
m.compose(new THREE.Vector3(x, y, z),
|
||
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), rotY),
|
||
new THREE.Vector3(scale, scale, scale));
|
||
trees.setMatrixAt(i, m);
|
||
treeData[i].alive = true;
|
||
treeData[i].x = x; treeData[i].z = z;
|
||
treeData[i].scale = scale; treeData[i].rotY = rotY;
|
||
treeData[i].burning = false; treeData[i].burnAge = 0;
|
||
// Farbe zurücksetzen falls vorher verbrannt
|
||
trees.setColorAt(i, new THREE.Color(1, 1, 1));
|
||
if (trees.instanceColor) trees.instanceColor.needsUpdate = true;
|
||
if (i >= trees.count) trees.count = i + 1;
|
||
trees.instanceMatrix.needsUpdate = true;
|
||
return true;
|
||
}
|
||
|
||
function spawnHouse(x, z) {
|
||
const i = findFreeSlot(houseData);
|
||
if (i < 0) return false;
|
||
const y = terrainHeightAt(x, z);
|
||
const m = new THREE.Matrix4();
|
||
const rotY = Math.random()*Math.PI*2;
|
||
m.compose(new THREE.Vector3(x, y, z),
|
||
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), rotY),
|
||
new THREE.Vector3(1, 1, 1));
|
||
houses.setMatrixAt(i, m);
|
||
houseData[i].alive = true;
|
||
houseData[i].x = x; houseData[i].z = z;
|
||
houseData[i].scale = 1; houseData[i].rotY = rotY;
|
||
houseData[i].burning = false; houseData[i].burnAge = 0; houseData[i].skullDone = false;
|
||
houses.setColorAt(i, new THREE.Color(1, 1, 1));
|
||
if (houses.instanceColor) houses.instanceColor.needsUpdate = true;
|
||
if (i >= houses.count) houses.count = i + 1;
|
||
houses.instanceMatrix.needsUpdate = true;
|
||
return true;
|
||
}
|
||
|
||
function findFreeSlot(arr) {
|
||
for (let i = 0; i < arr.length; i++) if (!arr[i].alive) return i;
|
||
return -1;
|
||
}
|
||
|
||
function destroyInstance(mesh, dataArr, i) {
|
||
dataArr[i].alive = false;
|
||
const m = new THREE.Matrix4().makeScale(0, 0, 0);
|
||
mesh.setMatrixAt(i, m);
|
||
mesh.instanceMatrix.needsUpdate = true;
|
||
}
|
||
|
||
function attemptSpawn() {
|
||
// Spawn-Bereich wächst mit dem Vulkan — immer außerhalb der typischen Lavareichweite
|
||
const safeMin = baseRadius() * 1.35 + 30;
|
||
const safeMax = baseRadius() * 1.35 + 230;
|
||
for (let attempt = 0; attempt < 8; attempt++) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = safeMin + Math.random() * (safeMax - safeMin);
|
||
const x = Math.cos(a) * r, z = Math.sin(a) * r;
|
||
if (isVegetated(x, z)) { spawnTree(x, z); break; }
|
||
}
|
||
if (Math.random() < 0.25) {
|
||
for (let attempt = 0; attempt < 5; attempt++) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = safeMin + 30 + Math.random() * (safeMax - safeMin - 40);
|
||
const x = Math.cos(a) * r, z = Math.sin(a) * r;
|
||
if (!isVegetated(x, z)) continue;
|
||
const h = terrainHeightAt(x, z);
|
||
const hN = terrainHeightAt(x + 6, z), hE = terrainHeightAt(x, z + 6);
|
||
const slope = Math.max(Math.abs(h - hN), Math.abs(h - hE));
|
||
if (slope < 1.5) {
|
||
spawnHouse(x, z);
|
||
checkChurchCluster(x, z);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function startBurn(dataArr, i) {
|
||
const d = dataArr[i];
|
||
if (!d.alive || d.burning) return;
|
||
d.burning = true;
|
||
d.burnAge = 0;
|
||
const a = Math.random() * Math.PI * 2;
|
||
d.fallX = Math.cos(a);
|
||
d.fallZ = Math.sin(a);
|
||
}
|
||
|
||
// Wird von depositLava aufgerufen — alles in Lava-Nähe fängt Feuer
|
||
function burnAt(x, z, radius) {
|
||
const r2 = radius * radius;
|
||
for (let i = 0; i < treeData.length; i++) {
|
||
const d = treeData[i];
|
||
if (!d.alive || d.burning) continue;
|
||
const dx = d.x - x, dz = d.z - z;
|
||
if (dx*dx + dz*dz < r2) startBurn(treeData, i);
|
||
}
|
||
for (let i = 0; i < houseData.length; i++) {
|
||
const d = houseData[i];
|
||
if (!d.alive || d.burning) continue;
|
||
const dx = d.x - x, dz = d.z - z;
|
||
if (dx*dx + dz*dz < r2) startBurn(houseData, i);
|
||
}
|
||
for (let i = 0; i < churchData.length; i++) {
|
||
const d = churchData[i];
|
||
if (!d.alive || d.burning) continue;
|
||
const dx = d.x - x, dz = d.z - z;
|
||
if (dx*dx + dz*dz < r2) startBurn(churchData, i);
|
||
}
|
||
}
|
||
|
||
function destroyInDangerZone() {
|
||
const expl = explosivity();
|
||
const base = baseRadius();
|
||
// Drei Zonen mit unterschiedlicher Reichweite:
|
||
const innerR = base * 1.05; // Vent-Bereich (immer)
|
||
const lavaR = base * 1.05 + expl * 120 + 30; // Lavafluss-Sektor
|
||
// Pyroklastischer Strom: erheblich weiter — bei expl≈0.85 reicht das ~330 Einheiten
|
||
// (echte VEI-5/6-Eruptionen zerstören Dörfer 30-50 km vom Vulkan entfernt)
|
||
const pyroR = base * 1.05 + expl * expl * 380 + 60;
|
||
|
||
function burnIfInZone(arr, i) {
|
||
if (!arr[i].alive) return;
|
||
const ex = arr[i].x, ez = arr[i].z;
|
||
const dist = Math.hypot(ex, ez);
|
||
if (dist < innerR) {
|
||
startBurn(arr, i);
|
||
} else if (dist < lavaR && inEruptionSector(ex, ez)) {
|
||
startBurn(arr, i);
|
||
} else if (expl > 0.35 && dist < pyroR && inEruptionSector(ex, ez)) {
|
||
// Pyroklastischer Strom zerstört ebenfalls — bei hoher VEI sehr weit
|
||
startBurn(arr, i);
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < treeData.length; i++) burnIfInZone(treeData, i);
|
||
for (let i = 0; i < houseData.length; i++) burnIfInZone(houseData, i);
|
||
if (typeof churchData !== 'undefined') {
|
||
for (let i = 0; i < churchData.length; i++) burnIfInZone(churchData, i);
|
||
}
|
||
}
|
||
|
||
// Pro-Frame-Animation für brennende Bäume & Häuser
|
||
function updateBurning(dt) {
|
||
const tmpM = new THREE.Matrix4();
|
||
const tmpP = new THREE.Vector3();
|
||
const tmpS = new THREE.Vector3();
|
||
const qY = new THREE.Quaternion();
|
||
const qFall = new THREE.Quaternion();
|
||
const q = new THREE.Quaternion();
|
||
const tmpColor = new THREE.Color();
|
||
const Y_AXIS = new THREE.Vector3(0, 1, 0);
|
||
const fallAxis = new THREE.Vector3();
|
||
let treesChanged = false, treesColorChanged = false;
|
||
let housesChanged = false, housesColorChanged = false;
|
||
|
||
for (let i = 0; i < treeData.length; i++) {
|
||
const d = treeData[i];
|
||
if (!d.alive || !d.burning) continue;
|
||
d.burnAge += dt;
|
||
const a = d.burnAge;
|
||
|
||
// Phase 1 (0-1.2s): verdunkeln (instanceColor multipliziert mit Vertex-Farbe)
|
||
const darkT = Math.min(1, a / 1.2);
|
||
const b = 1 - darkT * 0.92;
|
||
tmpColor.setRGB(b, b, b);
|
||
trees.setColorAt(i, tmpColor);
|
||
treesColorChanged = true;
|
||
|
||
// Phase 2 (1.0s+): umfallen — Rotation um die Basis
|
||
let fallAngle = 0;
|
||
if (a > 1.0) fallAngle = Math.min(Math.PI/2, (a - 1.0) / 1.5 * Math.PI/2);
|
||
|
||
// Phase 3 (2.6s+): schrumpfen
|
||
let scl = d.scale;
|
||
if (a > 2.6) scl = d.scale * Math.max(0, 1 - (a - 2.6) / 0.6);
|
||
|
||
const y = terrainHeightAt(d.x, d.z);
|
||
qY.setFromAxisAngle(Y_AXIS, d.rotY);
|
||
fallAxis.set(d.fallX, 0, d.fallZ);
|
||
qFall.setFromAxisAngle(fallAxis, fallAngle);
|
||
q.multiplyQuaternions(qFall, qY);
|
||
tmpP.set(d.x, y, d.z);
|
||
tmpS.set(scl, scl, scl);
|
||
tmpM.compose(tmpP, q, tmpS);
|
||
trees.setMatrixAt(i, tmpM);
|
||
treesChanged = true;
|
||
|
||
// Rauch: solange brennend, gelegentlich dunkle Rauchwolke
|
||
if (a < 2.0 && Math.random() < dt * 6) {
|
||
emitBurnSmoke(d.x, y + 3 * d.scale, d.z, 0x222222, 3, 7, 1.3);
|
||
}
|
||
|
||
if (a > 3.2) {
|
||
destroyInstance(trees, treeData, i);
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < houseData.length; i++) {
|
||
const d = houseData[i];
|
||
if (!d.alive || !d.burning) continue;
|
||
d.burnAge += dt;
|
||
const a = d.burnAge;
|
||
|
||
// Verdunkeln
|
||
const darkT = Math.min(1, a / 1.0);
|
||
const b = 1 - darkT * 0.85;
|
||
tmpColor.setRGB(b, b, b);
|
||
houses.setColorAt(i, tmpColor);
|
||
housesColorChanged = true;
|
||
|
||
// Schrumpfen ab 2.0s (Dach verschwindet, dann ganzes Haus)
|
||
let scl = d.scale;
|
||
if (a > 2.0) {
|
||
scl = d.scale * Math.max(0, 1 - (a - 2.0) / 1.2);
|
||
} else if (a > 1.4) {
|
||
// Vor dem Schrumpfen: Y-Stauchung (Dach "verbrennt" ab)
|
||
const t = (a - 1.4) / 0.6;
|
||
scl = d.scale;
|
||
const y = terrainHeightAt(d.x, d.z);
|
||
qY.setFromAxisAngle(Y_AXIS, d.rotY);
|
||
tmpP.set(d.x, y, d.z);
|
||
tmpS.set(scl, scl * (1 - t * 0.5), scl); // Dachpartie schmilzt
|
||
tmpM.compose(tmpP, qY, tmpS);
|
||
houses.setMatrixAt(i, tmpM);
|
||
housesChanged = true;
|
||
}
|
||
|
||
if (a <= 1.4 || a > 2.0) {
|
||
const y = terrainHeightAt(d.x, d.z);
|
||
qY.setFromAxisAngle(Y_AXIS, d.rotY);
|
||
tmpP.set(d.x, y, d.z);
|
||
tmpS.set(scl, a > 2.0 ? scl : scl, scl);
|
||
tmpM.compose(tmpP, qY, tmpS);
|
||
houses.setMatrixAt(i, tmpM);
|
||
housesChanged = true;
|
||
}
|
||
|
||
// Rauch
|
||
if (a < 2.5 && Math.random() < dt * 8) {
|
||
emitBurnSmoke(d.x, terrainHeightAt(d.x, d.z) + 2, d.z, 0x1a1a1a, 4, 10, 1.5);
|
||
}
|
||
|
||
// Totenkopf einmalig bei 0.8s
|
||
if (!d.skullDone && a > 0.8) {
|
||
d.skullDone = true;
|
||
emitSkull(d.x, terrainHeightAt(d.x, d.z) + 4, d.z);
|
||
}
|
||
|
||
if (a > 3.2) {
|
||
destroyInstance(houses, houseData, i);
|
||
}
|
||
}
|
||
|
||
// Kirchen brennen analog (höher → größerer Totenkopf, längere Verbrennzeit)
|
||
let churchesChanged = false, churchesColorChanged = false;
|
||
for (let i = 0; i < churchData.length; i++) {
|
||
const d = churchData[i];
|
||
if (!d.alive || !d.burning) continue;
|
||
d.burnAge += dt;
|
||
const a = d.burnAge;
|
||
|
||
const darkT = Math.min(1, a / 1.2);
|
||
const b = 1 - darkT * 0.85;
|
||
tmpColor.setRGB(b, b, b);
|
||
churches.setColorAt(i, tmpColor);
|
||
churchesColorChanged = true;
|
||
|
||
let scl = d.scale;
|
||
if (a > 2.4) scl = d.scale * Math.max(0, 1 - (a - 2.4) / 1.0);
|
||
|
||
const y = terrainHeightAt(d.x, d.z);
|
||
qY.setFromAxisAngle(Y_AXIS, d.rotY);
|
||
tmpP.set(d.x, y, d.z);
|
||
tmpS.set(scl, a > 1.6 && a <= 2.4 ? scl * (1 - (a-1.6)/0.8 * 0.4) : scl, scl);
|
||
tmpM.compose(tmpP, qY, tmpS);
|
||
churches.setMatrixAt(i, tmpM);
|
||
churchesChanged = true;
|
||
|
||
// Mehr Rauch, große Wolken
|
||
if (a < 3.0 && Math.random() < dt * 10) {
|
||
emitBurnSmoke(d.x, y + 6, d.z, 0x141414, 5, 14, 1.8);
|
||
}
|
||
|
||
// Größerer Totenkopf für Kirche
|
||
if (!d.skullDone && a > 1.0) {
|
||
d.skullDone = true;
|
||
emitSkull(d.x, terrainHeightAt(d.x, d.z) + 8, d.z);
|
||
}
|
||
|
||
if (a > 3.5) {
|
||
destroyInstance(churches, churchData, i);
|
||
}
|
||
}
|
||
|
||
if (treesChanged) trees.instanceMatrix.needsUpdate = true;
|
||
if (treesColorChanged && trees.instanceColor) trees.instanceColor.needsUpdate = true;
|
||
if (housesChanged) houses.instanceMatrix.needsUpdate = true;
|
||
if (housesColorChanged && houses.instanceColor) houses.instanceColor.needsUpdate = true;
|
||
if (churchesChanged) churches.instanceMatrix.needsUpdate = true;
|
||
if (churchesColorChanged && churches.instanceColor) churches.instanceColor.needsUpdate = true;
|
||
}
|
||
|
||
function updateSettlements(dt) {
|
||
const now = performance.now() / 1000;
|
||
if (!erupt.active && now - lastSpawnAttempt > 0.3) {
|
||
lastSpawnAttempt = now;
|
||
attemptSpawn();
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// TIERE
|
||
// ============================================================================
|
||
function animalTexture() {
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = 32;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = '#5a3a20';
|
||
ctx.beginPath();
|
||
ctx.ellipse(16, 18, 9, 4, 0, 0, Math.PI*2); ctx.fill();
|
||
ctx.fillRect(9, 20, 2, 6); ctx.fillRect(22, 20, 2, 6);
|
||
ctx.fillRect(13, 21, 2, 5); ctx.fillRect(19, 21, 2, 5);
|
||
ctx.beginPath();
|
||
ctx.ellipse(24, 15, 3.5, 3, 0, 0, Math.PI*2); ctx.fill();
|
||
return new THREE.CanvasTexture(c);
|
||
}
|
||
|
||
function createAnimals() {
|
||
const tex = animalTexture();
|
||
for (let i = 0; i < MAX_ANIMALS; i++) {
|
||
const s = new THREE.Sprite(new THREE.SpriteMaterial({
|
||
map: tex, transparent: true, depthWrite: false, opacity: 0,
|
||
}));
|
||
s.scale.set(0.01, 0.01, 1);
|
||
s.userData = { alive: false, vx: 0, vz: 0, t: 0 };
|
||
scene.add(s);
|
||
animals.push(s);
|
||
}
|
||
}
|
||
|
||
function spawnAnimal(x, z) {
|
||
for (const s of animals) {
|
||
if (!s.userData.alive) {
|
||
s.position.set(x, terrainHeightAt(x, z) + 2, z);
|
||
s.scale.set(6, 5, 1);
|
||
s.material.opacity = 1;
|
||
s.userData.alive = true;
|
||
const a = Math.random() * Math.PI * 2;
|
||
s.userData.vx = Math.cos(a) * 3;
|
||
s.userData.vz = Math.sin(a) * 3;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function updateAnimals(dt) {
|
||
const flee = erupt.active && erupt.t > 0.2;
|
||
const expl = explosivity();
|
||
for (const s of animals) {
|
||
if (!s.userData.alive) {
|
||
if (!erupt.active && Math.random() < dt * 0.4) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = 150 + Math.random() * 200;
|
||
const x = Math.cos(a)*r, z = Math.sin(a)*r;
|
||
if (isVegetated(x, z)) spawnAnimal(x, z);
|
||
}
|
||
continue;
|
||
}
|
||
if (flee) {
|
||
const d = Math.hypot(s.position.x, s.position.z) || 1;
|
||
s.userData.vx = (s.position.x / d) * (18 + expl * 12);
|
||
s.userData.vz = (s.position.z / d) * (18 + expl * 12);
|
||
} else if (Math.random() < dt * 0.5) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
s.userData.vx = Math.cos(a) * 3;
|
||
s.userData.vz = Math.sin(a) * 3;
|
||
}
|
||
s.position.x += s.userData.vx * dt;
|
||
s.position.z += s.userData.vz * dt;
|
||
const h = terrainHeightAt(s.position.x, s.position.z);
|
||
s.position.y = h + 2;
|
||
if (h < 0.5 || Math.hypot(s.position.x, s.position.z) > 360) {
|
||
s.userData.alive = false; s.scale.set(0.01,0.01,1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// GEFAHRENZONEN (3 Ringe)
|
||
// ============================================================================
|
||
function createDangerRings() {
|
||
const colors = [0xff0044, 0xff8800, 0xffdd00];
|
||
for (let i = 0; i < 3; i++) {
|
||
const mat = new THREE.MeshBasicMaterial({
|
||
color: colors[i], transparent: true, opacity: 0,
|
||
side: THREE.DoubleSide, depthWrite: false,
|
||
});
|
||
const m = new THREE.Mesh(new THREE.RingGeometry(1, 1.04, 64), mat);
|
||
m.rotation.x = -Math.PI/2;
|
||
m.position.y = 0.3;
|
||
scene.add(m);
|
||
dangerRings.push(m);
|
||
}
|
||
}
|
||
|
||
function updateDangerRings() {
|
||
const expl = explosivity();
|
||
const base = baseRadius();
|
||
const radii = [base*1.05 + expl*60, base*1.05 + expl*120 + 30, base*1.05 + expl*220 + 60];
|
||
for (let i = 0; i < 3; i++) {
|
||
const r = radii[i];
|
||
const old = dangerRings[i].geometry;
|
||
old.dispose();
|
||
dangerRings[i].geometry = new THREE.RingGeometry(r - 2, r, 80);
|
||
let op = (erupt.active ? 0.5 : 0.25);
|
||
if (i === 2) op = expl > 0.3 ? op * (expl-0.3) * 1.4 : 0;
|
||
if (i === 1) op = expl > 0.2 ? op * (expl-0.2) * 1.4 : op * 0.4;
|
||
dangerRings[i].material.opacity = Math.min(0.6, Math.max(0, op));
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// MESH-EFFEKTE (garantiert sichtbarer Flash + Schockwelle)
|
||
// ============================================================================
|
||
function createExplosionEffects() {
|
||
explosionFlash = new THREE.Mesh(
|
||
new THREE.SphereGeometry(1, 20, 14),
|
||
new THREE.MeshBasicMaterial({ color: 0xffe48a, transparent: true, opacity: 0, depthWrite: false })
|
||
);
|
||
explosionFlash.visible = false;
|
||
scene.add(explosionFlash);
|
||
|
||
shockRing = new THREE.Mesh(
|
||
new THREE.RingGeometry(1, 1.2, 64),
|
||
new THREE.MeshBasicMaterial({
|
||
color: 0xffcc88, transparent: true, opacity: 0,
|
||
side: THREE.DoubleSide, depthWrite: false,
|
||
})
|
||
);
|
||
shockRing.rotation.x = -Math.PI/2;
|
||
shockRing.position.y = 0.6;
|
||
shockRing.visible = false;
|
||
scene.add(shockRing);
|
||
}
|
||
|
||
// ============================================================================
|
||
// WOLKEN
|
||
// ============================================================================
|
||
function createClouds() {
|
||
const tex = TEX_CLOUD();
|
||
for (let i = 0; i < 10; i++) {
|
||
const s = new THREE.Sprite(new THREE.SpriteMaterial({
|
||
map: tex, transparent: true, opacity: 0.78, depthWrite: false,
|
||
}));
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = 250 + Math.random() * 350;
|
||
s.position.set(Math.cos(a)*r, 180 + Math.random()*100, Math.sin(a)*r);
|
||
const w = 180 + Math.random()*120;
|
||
s.scale.set(w, w*0.5, 1);
|
||
s.userData = { vx: 4 + Math.random()*5, baseOpacity: 0.7 + Math.random()*0.2 };
|
||
scene.add(s);
|
||
clouds.push(s);
|
||
}
|
||
}
|
||
|
||
function updateClouds(dt) {
|
||
const expl = explosivity();
|
||
for (const c of clouds) {
|
||
c.position.x += c.userData.vx * dt;
|
||
if (c.position.x > 700) c.position.x = -700;
|
||
const d = Math.hypot(c.position.x, c.position.z);
|
||
if (erupt.active && expl > 0.4 && d < 250) {
|
||
c.material.opacity = Math.max(0.05, c.userData.baseOpacity - (250-d)*0.003);
|
||
} else {
|
||
c.material.opacity = c.userData.baseOpacity;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// VÖGEL
|
||
// ============================================================================
|
||
function birdTexture() {
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = 32;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = '#1a1a1a';
|
||
ctx.beginPath();
|
||
ctx.moveTo(2, 18);
|
||
ctx.quadraticCurveTo(8, 8, 16, 16);
|
||
ctx.quadraticCurveTo(24, 8, 30, 18);
|
||
ctx.lineTo(28, 19);
|
||
ctx.quadraticCurveTo(24, 12, 16, 18);
|
||
ctx.quadraticCurveTo(8, 12, 4, 19);
|
||
ctx.closePath(); ctx.fill();
|
||
return new THREE.CanvasTexture(c);
|
||
}
|
||
|
||
function createBirds() {
|
||
const tex = birdTexture();
|
||
for (let i = 0; i < 14; i++) {
|
||
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = 200 + Math.random() * 200;
|
||
s.position.set(Math.cos(a)*r, 90 + Math.random()*60, Math.sin(a)*r);
|
||
s.scale.set(8, 4, 1);
|
||
const v = 7 + Math.random()*4;
|
||
s.userData = {
|
||
vx: -Math.sin(a)*v, vy: 0, vz: Math.cos(a)*v,
|
||
flap: Math.random()*Math.PI*2,
|
||
fleeing: false,
|
||
};
|
||
scene.add(s);
|
||
birds.push(s);
|
||
}
|
||
}
|
||
|
||
function updateBirds(dt) {
|
||
const expl = explosivity();
|
||
const flee = erupt.active && erupt.t > 0.3 && expl > 0.1;
|
||
for (const b of birds) {
|
||
if (flee && !b.userData.fleeing) {
|
||
const dx = b.position.x, dz = b.position.z;
|
||
const len = Math.hypot(dx, dz) || 1;
|
||
const sp = 35 + Math.random()*25 + expl*30;
|
||
b.userData.vx = (dx/len)*sp + (Math.random()-0.5)*6;
|
||
b.userData.vz = (dz/len)*sp + (Math.random()-0.5)*6;
|
||
b.userData.vy = 4 + Math.random()*6;
|
||
b.userData.fleeing = true;
|
||
}
|
||
b.position.x += b.userData.vx * dt;
|
||
b.position.y += b.userData.vy * dt;
|
||
b.position.z += b.userData.vz * dt;
|
||
b.userData.vy *= 0.985;
|
||
b.userData.flap += dt * 8;
|
||
if (!b.userData.fleeing) {
|
||
b.position.y += Math.sin(b.userData.flap) * 0.15;
|
||
b.position.y += (120 - b.position.y) * 0.5 * dt;
|
||
}
|
||
const flapY = 0.85 + Math.sin(b.userData.flap*1.5)*0.15;
|
||
b.scale.set(8, 4*flapY, 1);
|
||
if (Math.hypot(b.position.x, b.position.z) > 800) {
|
||
const a = Math.atan2(b.position.z, b.position.x) + Math.PI;
|
||
b.position.set(Math.cos(a)*300, 90+Math.random()*60, Math.sin(a)*300);
|
||
const v = 7 + Math.random()*4;
|
||
b.userData.vx = -Math.sin(a)*v;
|
||
b.userData.vz = Math.cos(a)*v;
|
||
b.userData.vy = 0;
|
||
b.userData.fleeing = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// PHYSIK
|
||
// ============================================================================
|
||
function explosivity() {
|
||
const trapped = params.gas * (0.15 + params.viscosity * 0.85);
|
||
const water = params.water * (0.4 + params.viscosity * 0.6);
|
||
return Math.min(1, trapped * 0.75 + water * 0.55);
|
||
}
|
||
|
||
// === LAVA: ballistisch (Fontäne) + Flow (am Hang) ===
|
||
function emitLavaFountain() {
|
||
const s = getFreeSprite(lavaPool);
|
||
const top = maxHeight();
|
||
const cr = craterRadius();
|
||
const base = baseRadius();
|
||
const expl = explosivity();
|
||
const a = Math.random() * Math.PI * 2;
|
||
s.position.set(Math.cos(a)*cr*0.35, top + 1, Math.sin(a)*cr*0.35);
|
||
const up = 32 + Math.random()*32 + expl*28;
|
||
const out = 5 + Math.random()*14;
|
||
s.userData.active = true;
|
||
s.userData.vx = Math.cos(a)*out;
|
||
s.userData.vy = up;
|
||
s.userData.vz = Math.sin(a)*out;
|
||
s.userData.ballistic = true;
|
||
s.userData.life = s.userData.maxLife = 4 + Math.random()*2;
|
||
s.userData.sizeStart = 8 + Math.random()*4;
|
||
s.userData.sizeEnd = s.userData.sizeStart * 0.6;
|
||
// Kürzere Reichweite — Lava staut sich nahe am Vulkan
|
||
s.userData.maxRad = base + 12 + Math.random() * (55 + (1-params.viscosity)*65); // breitere Verteilung — Abstand zum Vulkan
|
||
s.scale.set(s.userData.sizeStart, s.userData.sizeStart, 1);
|
||
s.material.color.setHex(0xff8844);
|
||
s.material.opacity = 1;
|
||
s.visible = true;
|
||
}
|
||
|
||
function emitLavaFlow() {
|
||
const s = getFreeSprite(lavaPool);
|
||
const top = maxHeight();
|
||
const cr = craterRadius();
|
||
const base = baseRadius();
|
||
// 70% folgen den Kanälen (bilden Rücken), 30% zufällig — aber alle im Sektor
|
||
let channel;
|
||
if (flowChannels.length > 0 && Math.random() < 0.7) {
|
||
channel = flowChannels[Math.floor(Math.random() * flowChannels.length)];
|
||
} else if (eruptionSector) {
|
||
// Random innerhalb des Sektors statt 360°
|
||
channel = eruptionSector.center + (Math.random() - 0.5) * eruptionSector.arc;
|
||
} else {
|
||
channel = Math.random() * Math.PI * 2;
|
||
}
|
||
const a = channel + (Math.random() - 0.5) * 0.6; // breitere Streuung (~17°)
|
||
const r = cr * (0.85 + Math.random()*0.3);
|
||
s.position.set(Math.cos(a)*r, top + 0.5, Math.sin(a)*r);
|
||
s.userData.active = true;
|
||
s.userData.ballistic = false;
|
||
s.userData.ang = a;
|
||
s.userData.rad = r;
|
||
s.userData.life = s.userData.maxLife = 8 + (1-params.viscosity)*8 + Math.random()*4;
|
||
s.userData.sizeStart = 9 + Math.random()*4;
|
||
s.userData.sizeEnd = s.userData.sizeStart * 0.8;
|
||
s.userData.maxRad = base + 12 + Math.random() * (55 + (1-params.viscosity)*65); // breitere Verteilung — Abstand zum Vulkan
|
||
s.scale.set(s.userData.sizeStart, s.userData.sizeStart, 1);
|
||
s.material.color.setHex(0xff6622);
|
||
s.material.opacity = 1;
|
||
s.visible = true;
|
||
}
|
||
|
||
// Wähle 3-4 Strömungskanäle für die aktuelle Eruption
|
||
function pickFlowChannels() {
|
||
flowChannels = [];
|
||
const n = 3 + Math.floor(Math.random() * 2); // 3 oder 4 Kanäle innerhalb des Sektors
|
||
|
||
const expl = explosivity();
|
||
// Sektor-Mittelpunkt: völlig zufällig pro Eruption — kein "Lieblingshang"
|
||
const sectorCenter = Math.random() * Math.PI * 2;
|
||
|
||
// Bogengröße abhängig von Explosivität:
|
||
// VEI 1-2 (expl ~0.15): 60-100° — nur eine Seite des Vulkans
|
||
// VEI 3-4 (expl ~0.50): 115-155° — gut ein Drittel
|
||
// VEI 5+ (expl ~0.85): 165-205° — fast eine ganze Hälfte
|
||
let arcDeg = 50 + expl * 130 + Math.random() * 30;
|
||
|
||
// Sehr selten: Sektorkollaps / Plinian-Eruption mit Rundum-Zerstörung
|
||
// (nur bei hoher Explosivität, ~8% Chance — wie Mt. St. Helens 1980 als Ausnahme)
|
||
if (expl > 0.7 && Math.random() < 0.08) arcDeg = 360;
|
||
|
||
const arc = arcDeg * Math.PI / 180;
|
||
eruptionSector = { center: sectorCenter, arc };
|
||
|
||
// Kanäle gleichmäßig innerhalb des Sektors verteilen
|
||
for (let i = 0; i < n; i++) {
|
||
let angle;
|
||
if (arc >= Math.PI * 2 - 0.01) {
|
||
// Rundum: gleichmäßig auf 360°
|
||
angle = sectorCenter + (i / n) * Math.PI * 2;
|
||
} else {
|
||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||
angle = sectorCenter + (t - 0.5) * arc + (Math.random() - 0.5) * 0.15;
|
||
}
|
||
flowChannels.push(angle);
|
||
}
|
||
|
||
// Visualisierung aktualisieren
|
||
rebuildSectorOverlay();
|
||
}
|
||
|
||
// Prüfung: liegt ein Punkt (x, z) im aktuellen Eruptions-Sektor?
|
||
function inEruptionSector(x, z) {
|
||
if (!eruptionSector) return true;
|
||
if (eruptionSector.arc >= Math.PI * 2 - 0.01) return true;
|
||
let diff = Math.atan2(z, x) - eruptionSector.center;
|
||
while (diff > Math.PI) diff -= Math.PI * 2;
|
||
while (diff < -Math.PI) diff += Math.PI * 2;
|
||
return Math.abs(diff) <= eruptionSector.arc / 2;
|
||
}
|
||
|
||
// Visualisierung: orangener Keil am Boden zeigt den betroffenen Sektor
|
||
function createSectorOverlay() {
|
||
sectorOverlay = new THREE.Mesh(
|
||
new THREE.BufferGeometry(),
|
||
new THREE.MeshBasicMaterial({
|
||
color: 0xff4422,
|
||
transparent: true,
|
||
opacity: 0,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
})
|
||
);
|
||
sectorOverlay.position.y = 0.35;
|
||
sectorOverlay.renderOrder = -1; // unter alles andere
|
||
scene.add(sectorOverlay);
|
||
}
|
||
|
||
function rebuildSectorOverlay() {
|
||
if (!sectorOverlay || !eruptionSector) return;
|
||
sectorOverlay.geometry.dispose();
|
||
const expl = explosivity();
|
||
const innerR = Math.max(2, baseRadius() * 0.95);
|
||
const outerR = baseRadius() * 1.05 + expl * 120 + 30;
|
||
const center = eruptionSector.center;
|
||
const arc = eruptionSector.arc;
|
||
const startAngle = center - arc / 2;
|
||
const steps = 48;
|
||
|
||
const positions = [];
|
||
const indices = [];
|
||
for (let i = 0; i <= steps; i++) {
|
||
const a = startAngle + (arc * i / steps);
|
||
positions.push(Math.cos(a)*innerR, 0, Math.sin(a)*innerR); // innen
|
||
positions.push(Math.cos(a)*outerR, 0, Math.sin(a)*outerR); // außen
|
||
}
|
||
for (let i = 0; i < steps; i++) {
|
||
const ia = i*2, ib = i*2+1, ic = (i+1)*2, id = (i+1)*2+1;
|
||
indices.push(ia, ib, ic, ib, id, ic);
|
||
}
|
||
const geom = new THREE.BufferGeometry();
|
||
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||
geom.setIndex(indices);
|
||
sectorOverlay.geometry = geom;
|
||
}
|
||
|
||
function updateSectorOverlay() {
|
||
if (!sectorOverlay) return;
|
||
const target = (erupt.active && eruptionSector) ? 0.30 : 0;
|
||
sectorOverlay.material.opacity += (target - sectorOverlay.material.opacity) * 0.045;
|
||
sectorOverlay.visible = sectorOverlay.material.opacity > 0.005;
|
||
}
|
||
|
||
// Sichtbare Lava-Ströme: flache Bänder direkt auf der Oberfläche.
|
||
// Vertices werden frame-by-frame aus dem Heightmap-Sampling neu berechnet,
|
||
// damit das Band dem wachsenden Terrain folgt — kein Schweben.
|
||
function createLavaStream(angle) {
|
||
const segments = 36;
|
||
const base = baseRadius();
|
||
const maxR = base + 25 + Math.random() * 45; // kurze Reichweite
|
||
|
||
// Initiale Position-Buffer (werden in updateLavaStreamGeometry gefüllt)
|
||
const positions = new Float32Array(segments * 2 * 3);
|
||
const colors = new Float32Array(segments * 2 * 3);
|
||
const indices = [];
|
||
for (let i = 0; i < segments - 1; i++) {
|
||
const a = i*2, b = i*2+1, c = (i+1)*2, d = (i+1)*2+1;
|
||
indices.push(a, b, c, b, d, c);
|
||
}
|
||
|
||
// Farbe als Funktion von t (Position entlang Strom): heiß am Krater → kühl am Ende
|
||
for (let i = 0; i < segments; i++) {
|
||
const t = i / (segments - 1);
|
||
const heat = Math.pow(1 - t, 0.7); // 1 am Anfang, 0 am Ende
|
||
const R = 1.0;
|
||
const G = 0.15 + heat * 0.65; // 0.15 (dunkel-rot) → 0.80 (orange-gelb)
|
||
const B = Math.max(0, heat - 0.7) * 0.5; // ein wenig gelb-weiß ganz am Anfang
|
||
colors[i*6 + 0] = R; colors[i*6 + 1] = G; colors[i*6 + 2] = B;
|
||
colors[i*6 + 3] = R; colors[i*6 + 4] = G; colors[i*6 + 5] = B;
|
||
}
|
||
|
||
const geom = new THREE.BufferGeometry();
|
||
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
geom.setIndex(indices);
|
||
const mat = new THREE.MeshBasicMaterial({
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0,
|
||
depthWrite: false,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
const mesh = new THREE.Mesh(geom, mat);
|
||
|
||
const width = 3.5 + volcanoGrowth * 4;
|
||
const stream = {
|
||
mesh, angle, maxR, segments, width,
|
||
age: 0, lifetime: erupt.duration + 3,
|
||
};
|
||
updateLavaStreamGeometry(stream);
|
||
return stream;
|
||
}
|
||
|
||
function updateLavaStreamGeometry(stream) {
|
||
const cr = craterRadius();
|
||
const tx = Math.cos(stream.angle), tz = Math.sin(stream.angle);
|
||
const px = -Math.sin(stream.angle), pz = Math.cos(stream.angle);
|
||
const pos = stream.mesh.geometry.attributes.position.array;
|
||
|
||
for (let i = 0; i < stream.segments; i++) {
|
||
const t = i / (stream.segments - 1);
|
||
const r = cr + (stream.maxR - cr) * t;
|
||
const cx = tx * r, cz = tz * r;
|
||
// Stromrand links/rechts (verbreitert sich leicht nach außen für Realismus)
|
||
const w = stream.width * (0.7 + 0.6 * t);
|
||
const lx = cx + px * w, lz = cz + pz * w;
|
||
const rx = cx - px * w, rz = cz - pz * w;
|
||
// Höhen direkt aus Heightmap — kein Spline, kein Smoothing → kein Schweben
|
||
const ly = Math.max(heightAtRadius(Math.hypot(lx, lz)), terrainHeightAt(lx, lz)) + 0.18;
|
||
const ry = Math.max(heightAtRadius(Math.hypot(rx, rz)), terrainHeightAt(rx, rz)) + 0.18;
|
||
pos[i*6 + 0] = lx; pos[i*6 + 1] = ly; pos[i*6 + 2] = lz;
|
||
pos[i*6 + 3] = rx; pos[i*6 + 4] = ry; pos[i*6 + 5] = rz;
|
||
}
|
||
stream.mesh.geometry.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
function createLavaStreams() {
|
||
// Alte Streams entfernen
|
||
for (const s of lavaStreams) {
|
||
scene.remove(s.mesh);
|
||
s.mesh.geometry.dispose();
|
||
s.mesh.material.dispose();
|
||
}
|
||
lavaStreams = [];
|
||
for (const angle of flowChannels) {
|
||
const s = createLavaStream(angle);
|
||
scene.add(s.mesh);
|
||
lavaStreams.push(s);
|
||
}
|
||
}
|
||
|
||
function updateLavaStreams(dt) {
|
||
for (let i = lavaStreams.length - 1; i >= 0; i--) {
|
||
const s = lavaStreams[i];
|
||
s.age += dt;
|
||
// Geometrie pro Frame neu sampeln — folgt wachsendem Terrain
|
||
updateLavaStreamGeometry(s);
|
||
|
||
const t = s.age / s.lifetime;
|
||
let op;
|
||
if (t < 0.05) op = (t / 0.05) * 0.95;
|
||
else if (t < 0.80) op = 0.95;
|
||
else op = Math.max(0, 0.95 * (1 - (t - 0.80) / 0.20));
|
||
s.mesh.material.opacity = op;
|
||
|
||
if (s.age >= s.lifetime) {
|
||
scene.remove(s.mesh);
|
||
s.mesh.geometry.dispose();
|
||
s.mesh.material.dispose();
|
||
lavaStreams.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateLava(dt) {
|
||
const flowSpeed = (1 - params.viscosity) * 28 + 5;
|
||
for (const s of lavaPool) {
|
||
if (!s.userData.active) continue;
|
||
s.userData.life -= dt;
|
||
if (s.userData.life <= 0) {
|
||
// Lava verglüht → deponiert ihre Masse ins Terrain
|
||
depositLava(s.position.x, s.position.z, 2.0);
|
||
s.userData.active = false;
|
||
s.visible = false;
|
||
continue;
|
||
}
|
||
if (s.userData.ballistic) {
|
||
s.position.x += s.userData.vx * dt;
|
||
s.position.y += s.userData.vy * dt;
|
||
s.position.z += s.userData.vz * dt;
|
||
s.userData.vy -= 55 * dt;
|
||
const xz = Math.hypot(s.position.x, s.position.z);
|
||
const surf = heightAtRadius(xz);
|
||
if (s.position.y <= surf + 0.5 && s.userData.vy < 0) {
|
||
s.position.y = surf + 0.5;
|
||
s.userData.ballistic = false;
|
||
s.userData.ang = Math.atan2(s.position.z, s.position.x);
|
||
s.userData.rad = Math.max(xz, 1);
|
||
s.material.color.setHex(0xdd4411);
|
||
}
|
||
} else {
|
||
s.userData.rad += flowSpeed * dt;
|
||
// Lava erreicht ihren Endpunkt → deponiert Masse ins Terrain
|
||
if (s.userData.rad > s.userData.maxRad) {
|
||
depositLava(s.position.x, s.position.z, 4.0);
|
||
s.userData.active = false;
|
||
s.visible = false;
|
||
continue;
|
||
}
|
||
const a = s.userData.ang, r = s.userData.rad;
|
||
// Folge entweder Vulkanhang oder Terrain (was höher ist)
|
||
const volcanoH = heightAtRadius(r);
|
||
const terrainH = terrainHeightAt(s.position.x, s.position.z);
|
||
s.position.set(Math.cos(a)*r, Math.max(volcanoH, terrainH) + 0.5, Math.sin(a)*r);
|
||
// Visuelle Abkühlung
|
||
const cool = Math.min(1, (r - craterRadius()) / 80);
|
||
const col = new THREE.Color(0xff7733).lerp(new THREE.Color(0x551511), cool);
|
||
s.material.color.copy(col);
|
||
}
|
||
const t = 1 - s.userData.life / s.userData.maxLife;
|
||
const sz = s.userData.sizeStart + (s.userData.sizeEnd - s.userData.sizeStart) * t;
|
||
s.scale.set(sz, sz, 1);
|
||
s.material.opacity = Math.min(1, s.userData.life / 1.5);
|
||
}
|
||
}
|
||
|
||
// === ASCHE: massive Aschesäule mit weiter Ausbreitung ===
|
||
function emitAsh() {
|
||
const s = getFreeSprite(ashPool);
|
||
const top = maxHeight();
|
||
const cr = craterRadius();
|
||
const expl = explosivity();
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = Math.random() * cr * 0.7;
|
||
s.position.set(Math.cos(a)*r, top + 2, Math.sin(a)*r);
|
||
const up = 35 + expl*110 + Math.random()*20;
|
||
s.userData.active = true;
|
||
s.userData.ballistic = false;
|
||
s.userData.vx = (Math.random()-0.5) * 10 * (0.3 + expl);
|
||
s.userData.vy = up;
|
||
s.userData.vz = (Math.random()-0.5) * 10 * (0.3 + expl);
|
||
// Lebensdauer 20-46s (war 14-33s)
|
||
s.userData.life = s.userData.maxLife = 22 + expl*16 + Math.random()*6;
|
||
// Größe verdoppelt
|
||
s.userData.sizeStart = 45 + Math.random()*20 + expl*35;
|
||
s.userData.sizeEnd = s.userData.sizeStart * (5 + expl*4);
|
||
s.scale.set(s.userData.sizeStart, s.userData.sizeStart, 1);
|
||
const darkness = 0.30 + expl*0.45;
|
||
const g = Math.floor(230 - darkness * 155);
|
||
s.material.color.setRGB(g/255, g/255, g/255);
|
||
s.material.opacity = 0.45; // transparent → durch Überlagerung Volumen
|
||
s.visible = true;
|
||
}
|
||
|
||
function updateAsh(dt) {
|
||
const top = maxHeight();
|
||
for (const s of ashPool) {
|
||
if (!s.userData.active) continue;
|
||
s.userData.life -= dt;
|
||
if (s.userData.life <= 0) {
|
||
s.userData.active = false;
|
||
s.visible = false;
|
||
continue;
|
||
}
|
||
s.position.x += s.userData.vx * dt;
|
||
s.position.y += s.userData.vy * dt;
|
||
s.position.z += s.userData.vz * dt;
|
||
s.userData.vy *= 0.99;
|
||
s.userData.vy -= 1.5 * dt; // weniger Schwerkraft → bleibt länger oben
|
||
// Mehr horizontale Turbulenz
|
||
s.userData.vx += (Math.random()-0.5) * 7 * dt;
|
||
s.userData.vz += (Math.random()-0.5) * 7 * dt;
|
||
// Stärkerer Wind in der Höhe → weite Drift
|
||
if (s.position.y > top + 25) {
|
||
s.userData.vx += 10 * dt;
|
||
s.userData.vz += 3 * dt;
|
||
}
|
||
const t = 1 - s.userData.life / s.userData.maxLife;
|
||
const sz = s.userData.sizeStart + (s.userData.sizeEnd - s.userData.sizeStart) * t;
|
||
s.scale.set(sz, sz, 1);
|
||
// Faden mit der Zeit zu vollständiger Transparenz
|
||
s.material.opacity = Math.max(0, Math.min(0.45, (s.userData.life / s.userData.maxLife) * 0.55));
|
||
}
|
||
}
|
||
|
||
// === PYROKLASTISCHE STRÖME: bodennah, rasend schnell ===
|
||
function emitPyro() {
|
||
const s = getFreeSprite(pyroPool);
|
||
const top = maxHeight();
|
||
const cr = craterRadius();
|
||
const expl = explosivity();
|
||
// Pyroklastische Ströme folgen dem Eruptions-Sektor (Topographie, Kraterrandbruch)
|
||
const a = eruptionSector
|
||
? eruptionSector.center + (Math.random() - 0.5) * eruptionSector.arc
|
||
: Math.random() * Math.PI * 2;
|
||
s.position.set(Math.cos(a)*cr*1.1, top - 2 + Math.random()*6, Math.sin(a)*cr*1.1);
|
||
const out = 30 + expl*55 + Math.random()*15;
|
||
s.userData.active = true;
|
||
s.userData.ballistic = false;
|
||
s.userData.vx = Math.cos(a)*out;
|
||
s.userData.vy = -8;
|
||
s.userData.vz = Math.sin(a)*out;
|
||
s.userData.life = s.userData.maxLife = 5 + Math.random()*4;
|
||
s.userData.sizeStart = 16 + Math.random()*8;
|
||
s.userData.sizeEnd = s.userData.sizeStart * 2.5;
|
||
s.scale.set(s.userData.sizeStart, s.userData.sizeStart, 1);
|
||
s.material.opacity = 0.78;
|
||
s.visible = true;
|
||
}
|
||
|
||
function updatePyro(dt) {
|
||
const base = baseRadius();
|
||
const expl = explosivity();
|
||
for (const s of pyroPool) {
|
||
if (!s.userData.active) continue;
|
||
s.userData.life -= dt;
|
||
if (s.userData.life <= 0) {
|
||
s.userData.active = false;
|
||
s.visible = false;
|
||
continue;
|
||
}
|
||
s.position.x += s.userData.vx * dt;
|
||
s.position.y += s.userData.vy * dt;
|
||
s.position.z += s.userData.vz * dt;
|
||
s.userData.vy -= 14 * dt;
|
||
// bodennah halten
|
||
const xz = Math.hypot(s.position.x, s.position.z);
|
||
const surf = heightAtRadius(xz);
|
||
if (s.position.y < surf + 3) {
|
||
s.position.y = surf + 3;
|
||
s.userData.vy = Math.max(s.userData.vy, -2);
|
||
}
|
||
if (xz > base + 80 + expl*80) s.userData.life = 0;
|
||
const t = 1 - s.userData.life / s.userData.maxLife;
|
||
const sz = s.userData.sizeStart + (s.userData.sizeEnd - s.userData.sizeStart) * t;
|
||
s.scale.set(sz, sz, 1);
|
||
s.material.opacity = Math.min(0.78, s.userData.life / 2);
|
||
}
|
||
}
|
||
|
||
// === IDLE-DAMPF ===
|
||
function emitSteam() {
|
||
const s = getFreeSprite(steamPool);
|
||
const top = maxHeight();
|
||
const cr = craterRadius();
|
||
const a = Math.random()*Math.PI*2;
|
||
const r = Math.random()*cr*0.5;
|
||
s.position.set(Math.cos(a)*r, top + 1, Math.sin(a)*r);
|
||
s.userData.active = true;
|
||
s.userData.vx = (Math.random()-0.5)*1.5;
|
||
s.userData.vy = 4 + Math.random()*3;
|
||
s.userData.vz = (Math.random()-0.5)*1.5;
|
||
s.userData.life = s.userData.maxLife = 4 + Math.random()*2;
|
||
s.userData.sizeStart = 8 + Math.random()*4;
|
||
s.userData.sizeEnd = s.userData.sizeStart * 2;
|
||
s.scale.set(s.userData.sizeStart, s.userData.sizeStart, 1);
|
||
s.material.opacity = 0.45;
|
||
s.visible = true;
|
||
}
|
||
|
||
function updateSteam(dt) {
|
||
for (const s of steamPool) {
|
||
if (!s.userData.active) continue;
|
||
s.userData.life -= dt;
|
||
if (s.userData.life <= 0) {
|
||
s.userData.active = false;
|
||
s.visible = false;
|
||
continue;
|
||
}
|
||
s.position.x += s.userData.vx * dt;
|
||
s.position.y += s.userData.vy * dt;
|
||
s.position.z += s.userData.vz * dt;
|
||
s.userData.vy *= 0.98;
|
||
const t = 1 - s.userData.life / s.userData.maxLife;
|
||
const sz = s.userData.sizeStart + (s.userData.sizeEnd - s.userData.sizeStart) * t;
|
||
s.scale.set(sz, sz, 1);
|
||
s.material.opacity = Math.min(0.45, s.userData.life / 1.5) * 0.6;
|
||
}
|
||
}
|
||
|
||
function emitBurnSmoke(x, y, z, hexColor, sizeStart, sizeEnd, life) {
|
||
const s = getFreeSprite(burnSmokePool);
|
||
s.position.set(x + (Math.random()-0.5)*1.5, y, z + (Math.random()-0.5)*1.5);
|
||
s.userData.active = true;
|
||
s.userData.vx = (Math.random()-0.5) * 1.5;
|
||
s.userData.vy = 3 + Math.random() * 3;
|
||
s.userData.vz = (Math.random()-0.5) * 1.5;
|
||
s.userData.life = s.userData.maxLife = life;
|
||
s.userData.sizeStart = sizeStart;
|
||
s.userData.sizeEnd = sizeEnd;
|
||
s.scale.set(sizeStart, sizeStart, 1);
|
||
s.material.color.setHex(hexColor);
|
||
s.material.opacity = 0.85;
|
||
s.visible = true;
|
||
}
|
||
|
||
function updateBurnSmoke(dt) {
|
||
for (const s of burnSmokePool) {
|
||
if (!s.userData.active) continue;
|
||
s.userData.life -= dt;
|
||
if (s.userData.life <= 0) {
|
||
s.userData.active = false;
|
||
s.visible = false;
|
||
continue;
|
||
}
|
||
s.position.x += s.userData.vx * dt;
|
||
s.position.y += s.userData.vy * dt;
|
||
s.position.z += s.userData.vz * dt;
|
||
s.userData.vy *= 0.97;
|
||
s.userData.vx *= 0.97;
|
||
s.userData.vz *= 0.97;
|
||
const t = 1 - s.userData.life / s.userData.maxLife;
|
||
const sz = s.userData.sizeStart + (s.userData.sizeEnd - s.userData.sizeStart) * t;
|
||
s.scale.set(sz, sz, 1);
|
||
s.material.opacity = Math.min(0.85, s.userData.life / 0.4) * 0.85;
|
||
}
|
||
}
|
||
|
||
function emitSkull(x, y, z) {
|
||
const s = getFreeSprite(skullPool);
|
||
s.position.set(x, y, z);
|
||
s.userData.active = true;
|
||
s.userData.life = s.userData.maxLife = 2.0;
|
||
s.userData.sizeStart = 4;
|
||
s.userData.sizeEnd = 9;
|
||
s.scale.set(4, 4, 1);
|
||
s.material.opacity = 1;
|
||
s.visible = true;
|
||
}
|
||
|
||
function updateSkulls(dt) {
|
||
for (const s of skullPool) {
|
||
if (!s.userData.active) continue;
|
||
s.userData.life -= dt;
|
||
if (s.userData.life <= 0) {
|
||
s.userData.active = false;
|
||
s.visible = false;
|
||
continue;
|
||
}
|
||
const t = 1 - s.userData.life / s.userData.maxLife;
|
||
s.position.y += 6 * dt; // steigt auf
|
||
const sz = s.userData.sizeStart + (s.userData.sizeEnd - s.userData.sizeStart) * t;
|
||
s.scale.set(sz, sz, 1);
|
||
s.material.opacity = t < 0.5 ? 1 : Math.max(0, 1 - (t - 0.5) * 2);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// ERUPTION
|
||
// ============================================================================
|
||
function triggerEruption() {
|
||
erupt.active = true;
|
||
erupt.t = 0;
|
||
const btn = document.getElementById('erupt-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = '🌋 AKTIVER AUSBRUCH';
|
||
|
||
const expl = explosivity();
|
||
shakeAmount = 2 + expl * 6;
|
||
|
||
// Strömungskanäle für diese Eruption festlegen
|
||
pickFlowChannels();
|
||
// Sichtbare Lavaströme als bodennahe Bänder erzeugen
|
||
createLavaStreams();
|
||
|
||
// Garantiert sichtbar: Flash + Schockwelle
|
||
flashTime = 0.7;
|
||
ringTime = 2.0;
|
||
ringMaxR = baseRadius() + 50 + expl * 200;
|
||
explosionFlash.position.set(0, maxHeight() + 5, 0);
|
||
explosionFlash.visible = true;
|
||
shockRing.visible = true;
|
||
|
||
// Sofortiger Sprite-Burst — sichtbar im ersten Frame
|
||
const lavaBurst = Math.floor(70 + (1 - params.viscosity) * 130);
|
||
const ashBurst = Math.floor(70 + expl * 320);
|
||
for (let i = 0; i < lavaBurst; i++) emitLavaFountain();
|
||
for (let i = 0; i < ashBurst; i++) emitAsh();
|
||
// Sofortige Vorbefüllung der Lava-Ströme entlang der Kanäle
|
||
for (let i = 0; i < 120; i++) emitLavaFlow();
|
||
if (expl > 0.35) {
|
||
const pyroBurst = Math.floor(expl * 60);
|
||
for (let i = 0; i < pyroBurst; i++) emitPyro();
|
||
}
|
||
|
||
craterLight.intensity = 6 + expl * 6;
|
||
|
||
// Bäume und Häuser in der Gefahrenzone zerstören
|
||
destroyInDangerZone();
|
||
}
|
||
|
||
function updateEruption(dt) {
|
||
if (!erupt.active) {
|
||
craterLight.intensity = 0.4 + Math.sin(performance.now()*0.002)*0.15;
|
||
return;
|
||
}
|
||
erupt.t += dt;
|
||
const expl = explosivity();
|
||
const phase = erupt.t / erupt.duration;
|
||
craterLight.intensity = 3 + expl*5 + Math.sin(performance.now()*0.012)*1.2;
|
||
|
||
if (expl > 0.5 && phase < 0.7) {
|
||
shakeAmount = Math.max(shakeAmount, 0.5 + (expl - 0.5) * 3);
|
||
}
|
||
|
||
if (phase < 1) {
|
||
const bell = phase < 0.05 ? phase/0.05 : (phase > 0.75 ? Math.max(0,(1-phase)/0.25) : 1);
|
||
// Deutlich mehr Material pro Sekunde
|
||
const ashRate = (50 + expl*260) * bell;
|
||
const lavaRate = (60 + (1 - params.viscosity)*170 - expl*50) * bell;
|
||
const pyroRate = expl > 0.35 ? (expl - 0.35) * 180 * bell : 0;
|
||
|
||
// Zusätzliche Fontänen-Bursts
|
||
if (Math.random() < dt * (1 + (1 - params.viscosity) * 3)) {
|
||
const n = 6 + Math.floor(Math.random()*8);
|
||
for (let i = 0; i < n; i++) emitLavaFountain();
|
||
}
|
||
|
||
emitAt(ashRate * dt, emitAsh);
|
||
emitAt(Math.max(0, lavaRate) * dt, emitLavaFlow);
|
||
emitAt(pyroRate * dt, emitPyro);
|
||
} else {
|
||
erupt.active = false;
|
||
const btn = document.getElementById('erupt-btn');
|
||
btn.disabled = false;
|
||
btn.textContent = '🔥 AUSBRUCH AUSLÖSEN';
|
||
|
||
// Vulkan wächst durch die Eruption — reaktiv auf das deponierte Material
|
||
eruptionCount++;
|
||
// Schild → Schicht-Evolution: sehr langsam (Magmaentwicklung über lange Zeit)
|
||
if (params.type < 0.85) {
|
||
params.type = Math.min(0.85, params.type + 0.025);
|
||
document.getElementById('type').value = Math.round(params.type * 100);
|
||
}
|
||
// Reihenfolge: erst Asche aufs Terrain, dann Vulkan an neue Umgebungshöhe anpassen
|
||
globalAshfall(); // baut Insel rund um den Vulkan auf
|
||
adaptVolcanoToTerrain(); // Kegel wächst auf das neue Maximum
|
||
buildVolcano(); // Mesh & Cap mit neuem Wachstum neu bauen
|
||
reseatVegetation();
|
||
updateInfo();
|
||
}
|
||
}
|
||
|
||
function emitAt(count, fn) {
|
||
const full = Math.floor(count);
|
||
for (let i = 0; i < full; i++) fn();
|
||
if (Math.random() < count - full) fn();
|
||
}
|
||
|
||
// ============================================================================
|
||
// UI / TEXT
|
||
// ============================================================================
|
||
function viscLabel(v) {
|
||
if (v < 0.2) return 'sehr niedrig';
|
||
if (v < 0.4) return 'niedrig';
|
||
if (v < 0.6) return 'mittel';
|
||
if (v < 0.8) return 'hoch';
|
||
return 'sehr hoch';
|
||
}
|
||
function magmaType(v) {
|
||
if (v < 0.25) return 'BASALTISCH';
|
||
if (v < 0.55) return 'ANDESITISCH';
|
||
if (v < 0.80) return 'DAZITISCH';
|
||
return 'RHYOLITHISCH';
|
||
}
|
||
function eruptionStyle(expl, water) {
|
||
if (water > 0.6 && expl > 0.3) return 'PHREATOMAGM.';
|
||
if (expl < 0.15) return 'EFFUSIV';
|
||
if (expl < 0.30) return 'HAWAIIAN.';
|
||
if (expl < 0.45) return 'STROMBOL.';
|
||
if (expl < 0.65) return 'VULKANISCH';
|
||
if (expl < 0.85) return 'PLINIANISCH';
|
||
return 'ULTRAPLINIAN.';
|
||
}
|
||
function vei(expl) {
|
||
if (expl < 0.10) return '0';
|
||
if (expl < 0.25) return '1 – 2';
|
||
if (expl < 0.45) return '2 – 3';
|
||
if (expl < 0.65) return '3 – 4';
|
||
if (expl < 0.85) return '4 – 5';
|
||
return '5 – 6';
|
||
}
|
||
|
||
function didactic() {
|
||
const v = params.viscosity, g = params.gas, w = params.water;
|
||
const e = explosivity();
|
||
if (w > 0.55 && v > 0.3)
|
||
return '<strong>Wasser + heißes Magma</strong> → schlagartige Verdampfung → <strong>phreatomagmatische Explosion</strong>.';
|
||
if (v < 0.25 && g < 0.5)
|
||
return 'Niedrige Viskosität → Gas entweicht leicht → <strong>ruhiger Lavafluss</strong> (typisch Schildvulkan).';
|
||
if (v > 0.65 && g > 0.5)
|
||
return 'Zähes Magma <strong>fängt Gas ein</strong> → Druck steigt → <strong>explosive Eruption</strong>.';
|
||
if (v > 0.5 && g < 0.3)
|
||
return 'Zähes Magma, wenig Gas → träger Lavadom, kann später kollabieren.';
|
||
if (g > 0.7 && v < 0.3)
|
||
return 'Viel Gas + dünnflüssig → <strong>Lavafontänen</strong> (hawaiianischer Stil).';
|
||
if (e > 0.4)
|
||
return 'Mittlere Viskosität + Gasdruck → <strong>pyroklastische Ströme</strong> rasen den Hang hinab.';
|
||
return 'Verändere die Regler, um Magma-Eigenschaften und Explosivität zu erforschen.';
|
||
}
|
||
|
||
function updateInfo() {
|
||
document.getElementById('type-val').textContent =
|
||
params.type < 0.3 ? 'Schildvulkan' : params.type > 0.7 ? 'Schichtvulkan' : 'Übergangsform';
|
||
document.getElementById('gas-val').textContent = Math.round(params.gas * 100) + ' %';
|
||
document.getElementById('visc-val').textContent = viscLabel(params.viscosity);
|
||
document.getElementById('water-val').textContent = Math.round(params.water * 100) + ' %';
|
||
document.getElementById('magma-type').textContent = magmaType(params.viscosity);
|
||
const e = explosivity();
|
||
document.getElementById('eruption-type').textContent = eruptionStyle(e, params.water);
|
||
document.getElementById('vei').textContent = vei(e);
|
||
document.getElementById('expl-fill').style.width = (e * 100) + '%';
|
||
document.getElementById('didactic-text').innerHTML = didactic();
|
||
}
|
||
|
||
function setupUI() {
|
||
document.getElementById('type').addEventListener('input', e => {
|
||
params.type = +e.target.value / 100;
|
||
buildVolcano();
|
||
updateInfo();
|
||
});
|
||
document.getElementById('gas').addEventListener('input', e => {
|
||
params.gas = +e.target.value / 100; updateInfo();
|
||
});
|
||
document.getElementById('viscosity').addEventListener('input', e => {
|
||
params.viscosity = +e.target.value / 100; updateInfo();
|
||
});
|
||
document.getElementById('water').addEventListener('input', e => {
|
||
params.water = +e.target.value / 100; updateInfo();
|
||
});
|
||
document.getElementById('erupt-btn').addEventListener('click', triggerEruption);
|
||
// Kamera-Presets + Slice-Mode
|
||
document.querySelectorAll('#views button').forEach(b => {
|
||
b.addEventListener('click', () => {
|
||
const view = b.dataset.view;
|
||
if (view === 'slice') {
|
||
enterSliceMode();
|
||
document.querySelectorAll('#views button').forEach(o => o.classList.toggle('active', o === b));
|
||
} else {
|
||
if (sliceMode) exitSliceMode();
|
||
setCameraPreset(view);
|
||
}
|
||
});
|
||
});
|
||
// Slice-Slider
|
||
const slicePosEl = document.getElementById('slice-pos');
|
||
const sliceValEl = document.getElementById('slice-value');
|
||
if (slicePosEl) {
|
||
slicePosEl.addEventListener('input', e => {
|
||
slicePos = parseFloat(e.target.value);
|
||
sliceValEl.textContent = slicePos.toFixed(0);
|
||
});
|
||
}
|
||
// Sichtbarkeits-Schalter
|
||
document.getElementById('toggle-water').addEventListener('click', toggleWater);
|
||
document.getElementById('toggle-cross').addEventListener('click', toggleCrossSection);
|
||
}
|
||
|
||
// ============================================================================
|
||
// KAMERA-STEUERUNG
|
||
// ============================================================================
|
||
function setupControls() {
|
||
const dom = renderer.domElement;
|
||
let dragging = false, lx = 0, ly = 0, pinch = 0;
|
||
|
||
dom.addEventListener('pointerdown', e => {
|
||
dragging = true; lx = e.clientX; ly = e.clientY;
|
||
dom.setPointerCapture(e.pointerId);
|
||
});
|
||
dom.addEventListener('pointermove', e => {
|
||
if (!dragging) return;
|
||
cam.azimuth -= (e.clientX - lx) * 0.006;
|
||
cam.elevation -= (e.clientY - ly) * 0.005;
|
||
cam.elevation = Math.max(0.05, Math.min(1.3, cam.elevation));
|
||
lx = e.clientX; ly = e.clientY;
|
||
updateCameraPos();
|
||
});
|
||
dom.addEventListener('pointerup', () => { dragging = false; });
|
||
dom.addEventListener('pointercancel', () => { dragging = false; });
|
||
|
||
dom.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
cam.distance *= (1 + e.deltaY * 0.0009);
|
||
cam.distance = Math.max(120, Math.min(600, cam.distance));
|
||
updateCameraPos();
|
||
}, { passive: false });
|
||
|
||
dom.addEventListener('touchstart', e => {
|
||
if (e.touches.length === 2) {
|
||
pinch = Math.hypot(e.touches[0].clientX - e.touches[1].clientX,
|
||
e.touches[0].clientY - e.touches[1].clientY);
|
||
}
|
||
}, { passive: true });
|
||
dom.addEventListener('touchmove', e => {
|
||
if (e.touches.length === 2) {
|
||
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX,
|
||
e.touches[0].clientY - e.touches[1].clientY);
|
||
if (pinch > 0) {
|
||
cam.distance *= pinch / d;
|
||
cam.distance = Math.max(120, Math.min(600, cam.distance));
|
||
updateCameraPos();
|
||
}
|
||
pinch = d;
|
||
dragging = false;
|
||
} else pinch = 0;
|
||
}, { passive: true });
|
||
dom.addEventListener('touchend', e => { if (e.touches.length < 2) pinch = 0; });
|
||
}
|
||
|
||
function updateCameraPos() {
|
||
const x = cam.target.x + cam.distance * Math.cos(cam.elevation) * Math.sin(cam.azimuth);
|
||
const y = cam.target.y + cam.distance * Math.sin(cam.elevation);
|
||
const z = cam.target.z + cam.distance * Math.cos(cam.elevation) * Math.cos(cam.azimuth);
|
||
camera.position.set(x, y, z);
|
||
camera.lookAt(cam.target);
|
||
}
|
||
|
||
// Kamera-Presets (verschiedene Ansichten)
|
||
const camPresets = {
|
||
default: { azimuth: Math.PI * 0.25, elevation: 0.35, distance: 300, targetY: null },
|
||
top: { azimuth: Math.PI * 0.25, elevation: 1.35, distance: 380, targetY: 5 },
|
||
side: { azimuth: Math.PI * 0.25, elevation: 0.08, distance: 340, targetY: 25 },
|
||
far: { azimuth: Math.PI * 0.25, elevation: 0.45, distance: 520, targetY: null },
|
||
};
|
||
|
||
function setCameraPreset(name) {
|
||
const p = camPresets[name];
|
||
if (!p) return;
|
||
const targetY = p.targetY !== null ? p.targetY : (maxHeight() * 0.4);
|
||
camTarget = {
|
||
fromAz: cam.azimuth, toAz: cam.azimuth, // Azimuth nicht ändern (Drehung bleibt)
|
||
fromEl: cam.elevation, toEl: p.elevation,
|
||
fromD: cam.distance, toD: p.distance,
|
||
fromTY: cam.target.y, toTY: targetY,
|
||
t: 0, dur: 1.3,
|
||
};
|
||
// Buttons visuell synchronisieren
|
||
document.querySelectorAll('#views button').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.view === name);
|
||
});
|
||
}
|
||
|
||
function easeInOut(k) {
|
||
return k < 0.5 ? 2*k*k : 1 - Math.pow(-2*k+2, 2)/2;
|
||
}
|
||
|
||
function updateCameraTransition(dt) {
|
||
if (!camTarget) return;
|
||
camTarget.t += dt;
|
||
const k = Math.min(1, camTarget.t / camTarget.dur);
|
||
const ke = easeInOut(k);
|
||
cam.elevation = camTarget.fromEl + (camTarget.toEl - camTarget.fromEl) * ke;
|
||
cam.distance = camTarget.fromD + (camTarget.toD - camTarget.fromD) * ke;
|
||
cam.target.y = camTarget.fromTY + (camTarget.toTY - camTarget.fromTY) * ke;
|
||
updateCameraPos();
|
||
if (k >= 1) camTarget = null;
|
||
}
|
||
|
||
function onResize() {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
}
|
||
|
||
// ============================================================================
|
||
// ANIMATION-LOOP
|
||
// ============================================================================
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
const dt = Math.min(0.05, clock.getDelta());
|
||
|
||
// Meereswellen animieren
|
||
if (sea) sea.material.uniforms.time.value = performance.now() * 0.0009;
|
||
|
||
// Lava-Cap pulsieren
|
||
if (lavaCap) {
|
||
lavaCap.material.opacity = 0.78 + Math.sin(performance.now()*0.003) * 0.15;
|
||
const e = explosivity();
|
||
lavaCap.material.color.setHSL(0.04 - e*0.02, 1.0, 0.45 + e*0.15);
|
||
}
|
||
|
||
// Idle-Dampf
|
||
if (!erupt.active && Math.random() < dt * 6) emitSteam();
|
||
|
||
// Updates
|
||
updateEruption(dt);
|
||
updateLava(dt);
|
||
updateAsh(dt);
|
||
updatePyro(dt);
|
||
updateSteam(dt);
|
||
updateDangerRings();
|
||
updateClouds(dt);
|
||
updateBirds(dt);
|
||
updateTerrain(dt);
|
||
updateSettlements(dt);
|
||
updateAnimals(dt);
|
||
updateBurning(dt);
|
||
updateBurnSmoke(dt);
|
||
updateSkulls(dt);
|
||
updateLavaStreams(dt);
|
||
updateSectorOverlay();
|
||
updateCameraTransition(dt);
|
||
updateClipPlane();
|
||
|
||
// Flash + Schockwelle
|
||
if (flashTime > 0) {
|
||
flashTime -= dt;
|
||
const t = 1 - flashTime / 0.7;
|
||
const e = explosivity();
|
||
const scale = 6 + Math.sin(t * Math.PI) * (38 + e*30);
|
||
explosionFlash.scale.set(scale, scale, scale);
|
||
explosionFlash.material.opacity = (1 - t) * 0.85;
|
||
if (flashTime <= 0) explosionFlash.visible = false;
|
||
}
|
||
if (ringTime > 0) {
|
||
ringTime -= dt;
|
||
const t = 1 - ringTime / 2.0;
|
||
const r = 8 + t * ringMaxR;
|
||
shockRing.scale.set(r, r, r);
|
||
shockRing.material.opacity = (1 - t) * 0.65;
|
||
if (ringTime <= 0) shockRing.visible = false;
|
||
}
|
||
|
||
// Kamera-Wackeln
|
||
if (shakeAmount > 0.001) {
|
||
camera.position.x += (Math.random()-0.5) * shakeAmount;
|
||
camera.position.y += (Math.random()-0.5) * shakeAmount;
|
||
shakeAmount *= 0.92;
|
||
}
|
||
|
||
// Cross-section side panel updates each frame when visible
|
||
if (sliceMode) drawCrossSection();
|
||
|
||
renderer.render(scene, camera);
|
||
if (shakeAmount > 0.001) updateCameraPos();
|
||
}
|
||
|
||
// ============================================================================
|
||
// START
|
||
// ============================================================================
|
||
init();
|
||
animate();
|
||
</script>
|
||
<script src="task-overlay.js"></script>
|
||
</body>
|
||
</html>
|