Files
Adminator fecd8bb9c9 Textsystem: Bis-Strich mit Leerzeichen (Zahl – Zahl) per Agent-Sweep
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>
2026-08-22 12:38:19 +02:00

3238 lines
112 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>