Klima 3D: Heli pro Airport, Flugzeug, Flut-Tönung, Graph-Top-Verlust

- Airport-Fahrzeuge: pro Flughafen eigener Heli + Flugzeug, in
  airportVehicles-Map verwaltet, über syncMeasureMeshes mit-synchronisiert.
  Phasen-Offsets per Airport-Index, damit mehrere Flughäfen nicht
  synchron laufen.
- Heli: landet jetzt korrekt auf dem H (padLocal aus userData statt
  hardcoded, inkl. LIFT-Korrektur). Alle ~3 Zyklen (deterministisch)
  Vulkan-Runde statt Airport-Kreis — Radius 9 um VOLCANO_X/Z.
- Flugzeug (makePlane, low-poly Cessna): 110-s-Zyklus — parkt am
  Runway-Anfang, Takeoff-Roll, Climb-Out nach -X, 50 s unsichtbar
  (Reise), Approach, Landing-Roll, Taxi. Propeller dreht.
- Wolken: Höhe auf ~7 abgesenkt (Vulkan-Gletscher-Höhe), fühlen sich
  jetzt wie Dunst um den Kegel an statt Himmels-Schleier.
- Flut-Tönung: applyFloodTintAll lerpt Material-Farben aller Mesh-
  Füße, die unter ocean.position.y liegen, Richtung Dunkelgrau und
  kippt sie bis 0.12 rad. Wirkt auf Maßnahmen + Dorf + Citizen-Strand.
- Graph-Top = verloren: neu endLevel(false, 'co2-top' | 'temp-top')
  wenn state.co2Ppm ≥ 700 oder state.currentTemp ≥ 19. Endscreen
  bekommt passende Icons (🌫 / 🌡) und Titel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 14:08:04 +02:00
parent c7b3636815
commit 0fe4377deb
+305 -62
View File
@@ -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.');