diff --git a/App/sims/klima/game-3d.html b/App/sims/klima/game-3d.html
index c305796..c3d7407 100644
--- a/App/sims/klima/game-3d.html
+++ b/App/sims/klima/game-3d.html
@@ -1646,7 +1646,17 @@ function simulateTick() {
reportLiveProgress();
}
- if (res.endReason === 'won') endLevel(true);
+ // Graph-Top = verloren: sobald CO₂ oder Temperatur die Obergrenze der
+ // Diagramme (700 ppm / 19 °C) erreicht, ist der Durchgang gekippt.
+ // Vor der Engine-endReason-Kette geprüft, damit Grenzwert-Verluste
+ // auch am MaxTick (würde sonst als 'won' zählen) noch greifen.
+ if (state.co2Ppm >= 700) {
+ state.levelWon = false; state.levelCompleted = true;
+ endLevel(false, 'co2-top');
+ } else if (state.currentTemp >= 19) {
+ state.levelWon = false; state.levelCompleted = true;
+ endLevel(false, 'temp-top');
+ } else if (res.endReason === 'won') endLevel(true);
else if (res.endReason === 'pleite') endLevel(false, 'pleite');
else if (res.endReason === 'ueberflutet') endLevel(false, 'ueberflutet');
else {
@@ -2569,6 +2579,11 @@ function sceneRender() {
if (rotor) rotor.rotation.z = tNow * 1.4;
}
+ // Flut-Tönung: sobald ein Gebäude unter Wasser gerät, Materialien
+ // Richtung dunkelgrau/schwarz lerpen + leicht nach hinten kippen
+ // ("defekt"-Look). Gilt für Maßnahmen, Dorf-Häuser und Citizen-Strand.
+ applyFloodTintAll();
+
// Mangroven-Gesundheit: ab 30 cm Meeresspiegel fangen sie an zu sterben,
// bei 70 cm sind sie komplett grau. Standard-Farben werden Richtung
// grau/dunkelbraun interpoliert — Versalzung + Überflutung sichtbar.
@@ -3095,6 +3110,10 @@ function syncMeasureMeshes() {
measuresGroup.remove(mesh);
placedMeshes.delete(key);
}
+ // Airport-Fahrzeuge (Heli + Flugzeug) an geänderten Airport-Bestand
+ // anpassen — muss nach syncMeasureMeshes laufen, damit der Airport-Mesh
+ // schon in placedMeshes liegt.
+ if (typeof syncAirportVehicles === 'function') syncAirportVehicles();
}
/* ============================================================
@@ -3857,81 +3876,244 @@ function makeHeli() {
skid2.position.z = -0.15; g.add(skid2);
return g;
}
-const heli = makeHeli();
-heli.visible = false;
-heli.userData.infoKey = 'heli_info';
-ambientGroup.add(heli);
+// --- Flugzeug (Cessna-artig, low-poly) ---
+// Wird pro Flughafen einmal angelegt. Macht einen langen Zyklus (Parken,
+// Start, Wegflug, Rückflug, Landung, Taxi). Propeller dreht konstant.
+function makePlane() {
+ const g = new THREE.Group();
+ const bodyMat = new THREE.MeshStandardMaterial({ color: 0xe8e8ea, roughness: 0.7 });
+ const trimMat = new THREE.MeshStandardMaterial({ color: 0x2a4a6a, roughness: 0.8 });
+ const darkMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 1 });
-// Heli-Flug-Zyklus (Sekunden): orbit → landing → sitzt → takeoff → orbit …
-// Der Pad sitzt durch den 2x-Scale am Airport bei (inst.pos + padLocal*2).
-const HELI_CYCLE = 36;
-function updateHeli(tNow) {
+ // Rumpf (Cylinder entlang X, Nase +X)
+ const fuselage = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.14, 0.10, 1.2, 10),
+ bodyMat
+ );
+ fuselage.rotation.z = Math.PI / 2;
+ g.add(fuselage);
+ // Cockpit (leicht dunkler Block oben)
+ const cockpit = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.13, 0.22), trimMat);
+ cockpit.position.set(0.12, 0.13, 0); g.add(cockpit);
+ // Flügel (über dem Rumpf, Hochdecker)
+ const wing = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.04, 1.6), bodyMat);
+ wing.position.set(0.0, 0.20, 0); g.add(wing);
+ // Heckflosse (vertikal)
+ const tail = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.25, 0.04), bodyMat);
+ tail.position.set(-0.55, 0.25, 0); g.add(tail);
+ // Höhenleitwerk (horizontal, Heck)
+ const htail = new THREE.Mesh(new THREE.BoxGeometry(0.20, 0.03, 0.5), bodyMat);
+ htail.position.set(-0.55, 0.18, 0); g.add(htail);
+ // Propeller-Nabe + Blätter
+ const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.08, 8), darkMat);
+ hub.rotation.z = Math.PI / 2;
+ hub.position.set(0.62, 0, 0); g.add(hub);
+ const prop = new THREE.Group();
+ prop.position.set(0.66, 0, 0);
+ const blade = new THREE.Mesh(new THREE.BoxGeometry(0.01, 0.55, 0.04), darkMat);
+ prop.add(blade);
+ g.add(prop);
+ g.userData.propRef = prop;
+ // Fahrwerk (zwei kleine Räder)
+ for (const sx of [-0.1, 0.1]) {
+ const strut = new THREE.Mesh(new THREE.CylinderGeometry(0.01, 0.01, 0.15, 6), darkMat);
+ strut.position.set(sx, -0.14, 0); g.add(strut);
+ const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.04, 10), darkMat);
+ wheel.rotation.x = Math.PI / 2;
+ wheel.position.set(sx, -0.22, 0); g.add(wheel);
+ }
+ return g;
+}
+
+// --- Airport-Fahrzeuge pro Flughafen ---
+// airportVehicles hält pro gebautem Airport einen Heli + Flugzeug.
+// Jedes Paar bekommt einen individuellen Phase-Offset, damit Helis
+// und Flugzeuge verschiedener Flughäfen nicht synchron laufen.
+const airportVehicles = new Map(); // airport-i → { heli, plane, idx, phaseOffset }
+
+function syncAirportVehicles() {
const airport = state.ownedMeasures && state.ownedMeasures.airport;
- const hasAirport = airport && airport.count > 0 && airport.instances && airport.instances[0];
- heli.visible = !!hasAirport;
- if (!hasAirport) return;
- const inst = airport.instances[0];
- if (!inst.pos) return;
+ const keep = new Set();
+ if (airport && airport.instances) {
+ airport.instances.forEach((inst, idx) => {
+ const key = instanceKey('airport', idx);
+ keep.add(key);
+ if (airportVehicles.has(key)) return;
+ const h = makeHeli();
+ h.userData.infoKey = 'heli_info';
+ h.visible = false;
+ ambientGroup.add(h);
+ const p = makePlane();
+ p.userData.infoKey = 'aviation';
+ p.visible = false;
+ ambientGroup.add(p);
+ airportVehicles.set(key, { heli: h, plane: p, idx, phaseOffset: idx * 17 });
+ });
+ }
+ for (const key of Array.from(airportVehicles.keys())) {
+ if (keep.has(key)) continue;
+ const v = airportVehicles.get(key);
+ ambientGroup.remove(v.heli); ambientGroup.remove(v.plane);
+ airportVehicles.delete(key);
+ }
+}
- // Airport-Mesh aus placedMeshes holen (Rotation beachten); fallback auf inst.pos
- const airportMesh = placedMeshes.get(instanceKey('airport', 0));
- const airportPos = airportMesh ? airportMesh.position : new THREE.Vector3(inst.pos.x, inst.pos.y, inst.pos.z);
- const airportRotY = airportMesh ? airportMesh.rotation.y : (inst.rotation || 0);
- const padLocal = { x: 0.85 * 2, y: 0.04 * 2, z: 0.55 * 2 }; // inkl. 2x-Scale
- const cosR = Math.cos(airportRotY), sinR = Math.sin(airportRotY);
- const padWorld = {
- x: airportPos.x + padLocal.x * cosR - padLocal.z * sinR,
- y: airportPos.y + padLocal.y,
- z: airportPos.z + padLocal.x * sinR + padLocal.z * cosR,
+const HELI_CYCLE = 42;
+const PLANE_CYCLE = 110;
+
+function updateAirportVehicles(tNow) {
+ const airport = state.ownedMeasures && state.ownedMeasures.airport;
+ if (!airport) return;
+ for (const [key, v] of airportVehicles) {
+ const inst = airport.instances && airport.instances[v.idx];
+ if (!inst || !inst.pos) { v.heli.visible = false; v.plane.visible = false; continue; }
+ const airportMesh = placedMeshes.get(key);
+ if (!airportMesh) continue;
+ updateSingleHeli(v, tNow, airportMesh);
+ updateSinglePlane(v, tNow, airportMesh);
+ }
+}
+
+// Lokale→Welt-Transformation für Airport-relative Punkte (inkl. Airport-Rotation)
+function airportLocalToWorld(airportMesh, lx, ly, lz) {
+ const cosR = Math.cos(airportMesh.rotation.y);
+ const sinR = Math.sin(airportMesh.rotation.y);
+ return {
+ x: airportMesh.position.x + lx * cosR - lz * sinR,
+ y: airportMesh.position.y + ly,
+ z: airportMesh.position.z + lx * sinR + lz * cosR,
};
+}
- const cycleT = tNow % HELI_CYCLE;
- let rotorSpeed = 18;
- if (cycleT < 20) {
- // Orbit-Phase: kreist über Airport-Zentrum
- const a = tNow * 0.45;
- heli.position.set(
- airportPos.x + Math.cos(a) * 3.5,
- airportPos.y + 2.8 + Math.sin(tNow * 0.9) * 0.15,
- airportPos.z + Math.sin(a) * 3.5
- );
- heli.rotation.y = a + Math.PI / 2;
- } else if (cycleT < 24) {
- // Landung: linearer Abstieg vom Orbit-Punkt zum Pad
- const t = (cycleT - 20) / 4; // 0..1
- const a = 20 * 0.45; // Endphase des Orbits
- const startX = airportPos.x + Math.cos(a) * 3.5;
- const startZ = airportPos.z + Math.sin(a) * 3.5;
- const startY = airportPos.y + 2.8;
+function updateSingleHeli(v, tNow, airportMesh) {
+ const heli = v.heli;
+ heli.visible = true;
+ // Heli-Pad-Offset aus dem Airport-Mesh (inkl. LIFT-Korrektur), × Scale 2
+ const padLocalRaw = airportMesh.userData.heliPadLocal || { x: 0.85, y: 0.28, z: 0.55 };
+ const padLocal = { x: padLocalRaw.x * 2, y: padLocalRaw.y * 2, z: padLocalRaw.z * 2 };
+ const padWorld = airportLocalToWorld(airportMesh, padLocal.x, padLocal.y, padLocal.z);
+ const rotY = airportMesh.rotation.y;
+
+ const tPhase = tNow + v.phaseOffset;
+ const cycleT = tPhase % HELI_CYCLE;
+ const cycleNum = Math.floor(tPhase / HELI_CYCLE);
+ // Alle ~3 Zyklen macht der Heli statt der üblichen Airport-Runde eine
+ // größere Runde um den Vulkan (VOLCANO_X,VOLCANO_Z). Deterministisch
+ // abhängig von cycleNum + phaseOffset → synchron über Reloads.
+ const volcanoTour = (cycleNum % 3) === (Math.floor(v.phaseOffset) % 3);
+ let rotorSpeed = 20;
+
+ if (cycleT < 24) {
+ // Orbit-Phase: je nach Modus Airport-Kreis oder Vulkan-Runde
+ const a = tPhase * 0.45;
+ if (volcanoTour) {
+ // Großer Bogen: Zentrum Vulkan, Radius 9, Höhe steigt leicht
+ const cx = VOLCANO_X, cz = VOLCANO_Z;
+ heli.position.set(
+ cx + Math.cos(a) * 9,
+ airportMesh.position.y + 5.5 + Math.sin(tPhase * 0.8) * 0.3,
+ cz + Math.sin(a) * 9
+ );
+ heli.rotation.y = a + Math.PI / 2;
+ } else {
+ heli.position.set(
+ airportMesh.position.x + Math.cos(a) * 3.5,
+ airportMesh.position.y + 2.8 + Math.sin(tPhase * 0.9) * 0.15,
+ airportMesh.position.z + Math.sin(a) * 3.5
+ );
+ heli.rotation.y = a + Math.PI / 2;
+ }
+ } else if (cycleT < 28) {
+ // Landung: linear zurück zum Pad
+ const t = (cycleT - 24) / 4;
+ const a = 24 * 0.45;
+ const startX = volcanoTour ? VOLCANO_X + Math.cos(a) * 9 : airportMesh.position.x + Math.cos(a) * 3.5;
+ const startZ = volcanoTour ? VOLCANO_Z + Math.sin(a) * 9 : airportMesh.position.z + Math.sin(a) * 3.5;
+ const startY = airportMesh.position.y + (volcanoTour ? 5.5 : 2.8);
heli.position.set(
lerp(startX, padWorld.x, t),
- lerp(startY, padWorld.y + 0.22, t), // Skids-Offset
+ lerp(startY, padWorld.y + 0.22, t),
lerp(startZ, padWorld.z, t)
);
heli.rotation.y = a + Math.PI / 2;
- } else if (cycleT < 32) {
- // Sitzt auf dem Pad — Rotor läuft langsamer aus und wieder an
+ } else if (cycleT < 37) {
+ // Sitzt auf dem Pad — Rotor läuft aus und wieder an
heli.position.set(padWorld.x, padWorld.y + 0.22, padWorld.z);
- heli.rotation.y = airportRotY + Math.PI / 2;
- const sitT = cycleT - 24; // 0..8
- rotorSpeed = sitT < 2 ? 18 * (1 - sitT / 2) : (sitT > 6 ? 18 * (sitT - 6) / 2 : 0);
+ heli.rotation.y = rotY + Math.PI / 2;
+ const sitT = cycleT - 28;
+ rotorSpeed = sitT < 2 ? 20 * (1 - sitT / 2) : (sitT > 7 ? 20 * (sitT - 7) / 2 : 0);
} else {
- // Takeoff: Aufstieg vom Pad in den Orbit-Startpunkt
- const t = (cycleT - 32) / 4; // 0..1
- const a = (HELI_CYCLE) * 0.45; // nächster Orbit-Start
- const endX = airportPos.x + Math.cos(a) * 3.5;
- const endZ = airportPos.z + Math.sin(a) * 3.5;
- const endY = airportPos.y + 2.8;
+ // Takeoff zurück in den Orbit-Startpunkt
+ const t = (cycleT - 37) / (HELI_CYCLE - 37);
+ const a = HELI_CYCLE * 0.45;
+ const endX = airportMesh.position.x + Math.cos(a) * 3.5;
+ const endZ = airportMesh.position.z + Math.sin(a) * 3.5;
+ const endY = airportMesh.position.y + 2.8;
heli.position.set(
lerp(padWorld.x, endX, t),
lerp(padWorld.y + 0.22, endY, t),
lerp(padWorld.z, endZ, t)
);
- heli.rotation.y = airportRotY + Math.PI / 2;
+ heli.rotation.y = rotY + Math.PI / 2;
+ }
+ const r = heli.userData.rotorRef;
+ if (r) r.rotation.y = tPhase * rotorSpeed;
+}
+
+function updateSinglePlane(v, tNow, airportMesh) {
+ const plane = v.plane;
+ const rotY = airportMesh.rotation.y;
+ const RUNWAY_END = 2.2; // lokal, nach 2x-Scale also 4.4 in Welt
+ const RUNWAY_Y = 0.50; // auf Plateau-Oberkante (LIFT+Scale²≈0.26)
+
+ // Zyklus-Phase (Sekunden) mit individuellem Offset pro Airport, plus
+ // +60 s Grund-Offset damit Plane nicht sofort beim Level-Start startet.
+ const tPhase = (tNow + v.phaseOffset + 60) % PLANE_CYCLE;
+ plane.visible = true;
+ const prop = plane.userData.propRef;
+
+ // Hilfsfunktion: Plane an lokal (lx, ly, lz) mit Rotation-Offset um Y
+ function place(lx, ly, lz, pitch, extraYaw) {
+ const w = airportLocalToWorld(airportMesh, lx, ly, lz);
+ plane.position.set(w.x, w.y, w.z);
+ // Basis-Yaw = airportRotY + extraYaw (π für Rollen nach -X, 0 für +X)
+ plane.rotation.set(pitch || 0, rotY + (extraYaw || 0), 0);
}
- const r = heli.userData.rotorRef;
- if (r) r.rotation.y = tNow * rotorSpeed;
+ if (tPhase < 14) {
+ // Parken am Runway-Anfang (+X-Ende lokal), Nase zeigt nach Startrichtung (-X)
+ place(RUNWAY_END - 0.3, RUNWAY_Y, 0, 0, Math.PI);
+ } else if (tPhase < 18) {
+ // Takeoff-Roll: rollt von +X nach -X, hebt am Ende leicht ab
+ const t = (tPhase - 14) / 4;
+ const lx = (RUNWAY_END - 0.3) - t * (2 * RUNWAY_END - 0.6);
+ const ly = RUNWAY_Y + (t > 0.75 ? (t - 0.75) * 0.6 : 0);
+ place(lx, ly, 0, -t * 0.2, Math.PI);
+ } else if (tPhase < 26) {
+ // Climb-Out: steigt auf, entfernt sich weiter in -X-lokal
+ const t = (tPhase - 18) / 8;
+ const lx = -(RUNWAY_END - 0.3) - t * 18;
+ const ly = RUNWAY_Y + 0.15 + t * 4.0;
+ place(lx, ly, 0, -0.28, Math.PI);
+ } else if (tPhase < 80) {
+ // Unterwegs — unsichtbar (kommt von weit draußen zurück)
+ plane.visible = false;
+ } else if (tPhase < 90) {
+ // Approach: kommt in -X-lokal auf die Landung zu, sinkt
+ const t = (tPhase - 80) / 10;
+ const lx = -22 + t * (22 - (RUNWAY_END + 0.3));
+ const ly = 4.2 - t * 3.7; // 4.2 → 0.5
+ place(lx, ly, 0, 0.15 - t * 0.15, Math.PI);
+ } else if (tPhase < 94) {
+ // Landing-Roll: von -X nach +X mit leichtem Pitch-Up am Anfang, dann flach
+ const t = (tPhase - 90) / 4;
+ const lx = -(RUNWAY_END - 0.3) + t * (2 * RUNWAY_END - 0.6);
+ place(lx, RUNWAY_Y, 0, 0, Math.PI);
+ } else {
+ // Taxi + parken (zurück in Startposition)
+ place(RUNWAY_END - 0.3, RUNWAY_Y, 0, 0, Math.PI);
+ }
+ if (prop && plane.visible) prop.rotation.x = tPhase * 22;
}
// --- Start-Dorf: 10 Haus-Positionen auf der Insel, Sichtbarkeit skaliert mit Bevölkerung ---
@@ -4146,10 +4328,13 @@ const clouds = [];
for (let i = 0; i < 4; i++) {
const c = makeCloud();
c.userData.phase = Math.random(); // 0..1 Startverschiebung
- c.userData.height = 13 + Math.random() * 4; // y
+ // Höhe nahe Vulkan-Spitze (Terrain ≈ 5.2 + Dome 2.1 ≈ 7.3) — Wolken
+ // ziehen knapp über die Gletscherkuppe, statt hoch am Himmel zu
+ // schweben. Fühlbarer "Dunst um den Vulkan"-Look.
+ c.userData.height = 6.5 + Math.random() * 1.8;
c.userData.zStart = 4 + Math.random() * 6; // Insel-Vorder-Bereich
c.userData.zEnd = -16 - Math.random() * 4; // hinterm Vulkan
- c.userData.scale = 0.8 + Math.random() * 0.5;
+ c.userData.scale = 0.75 + Math.random() * 0.45;
c.userData.speed = 0.8 + Math.random() * 0.6;
c.scale.setScalar(c.userData.scale);
ambientGroup.add(c);
@@ -4276,10 +4461,56 @@ function handleTreesFelled(count, currCount) {
showEvent('🪓', msg, 'bad', 'blackout');
}
+// --- Flut-Tönung ---
+// Sobald der Ozean über einem Mesh-Fußpunkt steht, lerpen wir dessen
+// Material-Farben Richtung Dunkelgrau (defekter, schwarzer Look) und
+// kippen das Mesh leicht nach hinten (collapsing). Die Original-Farben
+// werden lazy beim ersten Flood-Check in userData.origColors gecacht.
+const FLOOD_DEEP = 0.55; // Welt-Einheiten unter Wasserlinie für volle Tönung
+const FLOOD_DARK = { r: 0.12, g: 0.12, b: 0.12 };
+
+function cacheOriginalColors(mesh) {
+ if (mesh.userData.origColors) return;
+ const cache = [];
+ mesh.traverse((obj) => {
+ if (obj.isMesh && obj.material && obj.material.color) {
+ cache.push({ mat: obj.material, c: obj.material.color.clone() });
+ }
+ });
+ mesh.userData.origColors = cache;
+ mesh.userData.origRotX = mesh.rotation.x || 0;
+}
+
+function tintMeshFlood(mesh, floodT) {
+ cacheOriginalColors(mesh);
+ for (const { mat, c } of mesh.userData.origColors) {
+ mat.color.setRGB(
+ c.r + (FLOOD_DARK.r - c.r) * floodT,
+ c.g + (FLOOD_DARK.g - c.g) * floodT,
+ c.b + (FLOOD_DARK.b - c.b) * floodT
+ );
+ }
+ // Leichter Kipp nach hinten, bis −0.12 rad (≈ 7°)
+ mesh.rotation.x = mesh.userData.origRotX - floodT * 0.12;
+}
+
+function applyFloodTintAll() {
+ const waterY = ocean.position.y;
+ const targets = [];
+ for (const mesh of placedMeshes.values()) targets.push(mesh);
+ for (const house of villageHouses) if (house.visible) targets.push(house);
+ if (citizenBeach) targets.push(citizenBeach);
+ for (const m of targets) {
+ const footY = m.position.y;
+ const floodT = Math.max(0, Math.min(1, (waterY - footY) / FLOOD_DEEP));
+ tintMeshFlood(m, floodT);
+ }
+}
+
// Zentrale Ambient-Update-Funktion (aus sceneRender pro Frame)
function updateAmbient(tNow, dt) {
updateBoats(tNow);
- updateHeli(tNow);
+ updateAirportVehicles(tNow);
updateVillage(tNow, dt);
updateClouds(tNow);
updateFallenTrees(dt);
@@ -4662,8 +4893,20 @@ function showEndScreen(stars, score, reason) {
const achievedCount = goals.filter(g => g.achieved).length;
const totalCount = goals.length;
- $('#end-icon').textContent = won ? '🏆' : (reason === 'pleite' ? '💸' : '🌊');
- $('#end-title').textContent = won ? 'Ziel erreicht!' : (reason === 'pleite' ? 'Budget aufgebraucht' : (reason === 'ueberflutet' ? 'Küste überflutet' : 'Ziel nicht erreicht'));
+ const icon = won ? '🏆'
+ : reason === 'pleite' ? '💸'
+ : reason === 'ueberflutet' ? '🌊'
+ : reason === 'co2-top' ? '🌫'
+ : reason === 'temp-top' ? '🌡'
+ : '⚠️';
+ const title = won ? 'Ziel erreicht!'
+ : reason === 'pleite' ? 'Budget aufgebraucht'
+ : reason === 'ueberflutet' ? 'Küste überflutet'
+ : reason === 'co2-top' ? 'CO₂-Gefahrenzone erreicht'
+ : reason === 'temp-top' ? 'Klima kippt — zu heiß'
+ : 'Ziel nicht erreicht';
+ $('#end-icon').textContent = icon;
+ $('#end-title').textContent = title;
$('#end-subtitle').textContent = won
? ('Du hast bis ' + (state.startYear + state.tick) + ' alle ' + totalCount + ' Hauptziele erreicht. Hervorragende Leistung!')
: ('Im Jahr ' + (state.startYear + state.tick) + ' ist der Durchgang beendet — ' + achievedCount + ' von ' + totalCount + ' Zielen erreicht.');