diff --git a/App/sims/klima/game-3d.html b/App/sims/klima/game-3d.html
index 5cc065a..8651330 100644
--- a/App/sims/klima/game-3d.html
+++ b/App/sims/klima/game-3d.html
@@ -1590,9 +1590,33 @@ function simulateTick() {
* kritisch wird (Budget negativ ODER Temperatur > 17 °C). Sobald sich die
* Lage entspannt, kommt der vorher gehörte Track zurück. Hat der Spieler
* selbst aus dem Dropdown etwas ausgewählt, wird NICHT umgeschaltet. */
+// Merker für Edge-Detection (pro View-Instanz). Reset in startGame.
+let _wasBudgetCritical = false;
+let _wasTempCritical = false;
+let _wasFloodCritical = false;
+
function updateCrisisMusic() {
if (!window.ggsMusicSetAuto) return;
- const critical = state.budget < 0 || state.currentTemp > 17 || state.floodedPct > 30;
+ const budgetCrit = state.budget < 0;
+ const tempCrit = state.currentTemp > 17;
+ const floodCrit = state.floodedPct > 30;
+ const critical = budgetCrit || tempCrit || floodCrit;
+
+ // Steigende Flanke Budget → Info-Karte erklärt das Umschalten der Musik
+ // und was jetzt zu tun ist. `infoKey: 'budget'` macht die Karte klickbar.
+ if (budgetCrit && !_wasBudgetCritical) {
+ showEvent('💸', 'Dein Budget ist ins Minus gerutscht — die Stadt macht Schulden. Reiss teure Wartungs-Maßnahmen ab oder baue zunächst nichts Neues. Die Musik wechselt auf Drama, bis du wieder im Plus bist.', 'bad', 'budget');
+ }
+ if (tempCrit && !_wasTempCritical) {
+ showEvent('🌡', 'Du hast +2 °C überschritten — das Pariser Klimaziel ist verfehlt. Viele Folgen sind jetzt unvermeidbar.', 'bad', 'temperature');
+ }
+ if (floodCrit && !_wasFloodCritical) {
+ showEvent('🌊', 'Über 30 % der Stadt sind überflutet — der Durchgang droht zu kippen. Jetzt dringend Küstenschutz aufstocken.', 'bad', 'sealevel');
+ }
+ _wasBudgetCritical = budgetCrit;
+ _wasTempCritical = tempCrit;
+ _wasFloodCritical = floodCrit;
+
if (critical) window.ggsMusicSetAuto('drama');
else window.ggsMusicRestoreAuto();
}
@@ -2748,52 +2772,64 @@ function makeCoalPlant() {
function makeAirport() {
const g = new THREE.Group();
+ // Plateau-Sockel: hebt den Flughafen sichtbar aus dem Terrain und
+ // verhindert, dass Teile am Hang im Berg verschwinden. Zwei-Schichten-
+ // Look (Unterkante etwas breiter, Oberkante heller).
+ const plateauBase = new THREE.Mesh(
+ new THREE.BoxGeometry(3.4, 0.20, 1.55),
+ new THREE.MeshStandardMaterial({ color: 0x50555a, roughness: 1 })
+ );
+ plateauBase.position.y = 0.10; g.add(plateauBase);
+ const plateauTop = new THREE.Mesh(
+ new THREE.BoxGeometry(3.3, 0.04, 1.48),
+ new THREE.MeshStandardMaterial({ color: 0x787d82, roughness: 1 })
+ );
+ plateauTop.position.y = 0.22; g.add(plateauTop);
+ // Alle Airport-Strukturen um LIFT anheben (sitzen jetzt auf der Plateau-Kante)
+ const LIFT = 0.24;
+
const runway = new THREE.Mesh(
new THREE.BoxGeometry(2.4, 0.04, 0.45),
new THREE.MeshStandardMaterial({ color: 0x404048, roughness: 1 })
);
- runway.position.y = 0.02; g.add(runway);
+ runway.position.y = 0.02 + LIFT; g.add(runway);
for (let i = -2; i <= 2; i++) {
const stripe = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.002, 0.05),
new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 1 })
);
- stripe.position.set(i * 0.4, 0.045, 0); g.add(stripe);
+ stripe.position.set(i * 0.4, 0.045 + LIFT, 0); g.add(stripe);
}
const terminal = new THREE.Mesh(
new THREE.BoxGeometry(0.8, 0.32, 0.45),
new THREE.MeshStandardMaterial({ color: 0xe4e0d4, roughness: 1 })
);
- terminal.position.set(-0.6, 0.18, 0.55); g.add(terminal);
+ terminal.position.set(-0.6, 0.18 + LIFT, 0.55); g.add(terminal);
const tower = new THREE.Mesh(
new THREE.CylinderGeometry(0.07, 0.08, 0.55, 8),
new THREE.MeshStandardMaterial({ color: 0xd0c8b4, roughness: 1 })
);
- tower.position.set(-0.95, 0.43, 0.55); g.add(tower);
+ tower.position.set(-0.95, 0.43 + LIFT, 0.55); g.add(tower);
const towerTop = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.10, 0.18),
new THREE.MeshStandardMaterial({ color: 0x2a4a6a, roughness: 0.6 })
);
- towerTop.position.set(-0.95, 0.78, 0.55); g.add(towerTop);
- // Heli-Pad: durchgehend schwarze Scheibe mit weißem H-Marking in der
- // Mitte. Keine farbigen Ränder — klar lesbar als Landeplatz.
+ towerTop.position.set(-0.95, 0.78 + LIFT, 0.55); g.add(towerTop);
+ // Heli-Pad: durchgehend schwarze Scheibe mit weißem H-Marking
const padBlack = new THREE.MeshStandardMaterial({ color: 0x000000, roughness: 1 });
const padWhite = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1 });
const pad = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.02, 28), padBlack);
- pad.position.set(0.85, 0.03, 0.55); g.add(pad);
- // H aus drei weißen Balken, flach oben auf dem Pad (Y-Achse = Dicke)
+ pad.position.set(0.85, 0.03 + LIFT, 0.55); g.add(pad);
const hBarL = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.006, 0.28), padWhite);
- hBarL.position.set(0.73, 0.045, 0.55); g.add(hBarL);
+ hBarL.position.set(0.73, 0.045 + LIFT, 0.55); g.add(hBarL);
const hBarR = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.006, 0.28), padWhite);
- hBarR.position.set(0.97, 0.045, 0.55); g.add(hBarR);
+ hBarR.position.set(0.97, 0.045 + LIFT, 0.55); g.add(hBarR);
const hBarM = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.006, 0.06), padWhite);
- hBarM.position.set(0.85, 0.045, 0.55); g.add(hBarM);
- // Pad-Welt-Offset relativ zum Airport-Gruppen-Zentrum merken (inkl. 2x-Scale);
- // der Heli nutzt das als Lande-Position.
- g.userData.heliPadLocal = { x: 0.85, y: 0.04, z: 0.55 };
- // Doppelte Größe: Terminal dominiert jetzt klar sichtbar die Insel.
+ hBarM.position.set(0.85, 0.045 + LIFT, 0.55); g.add(hBarM);
+ // Heli-Offset inkl. LIFT, damit die Lande-Position korrekt ist
+ g.userData.heliPadLocal = { x: 0.85, y: 0.04 + LIFT, z: 0.55 };
g.scale.setScalar(2);
- g.userData.isAirport = true; // für Heli-Anker
+ g.userData.isAirport = true;
return g;
}
@@ -2882,63 +2918,30 @@ function makeMangrove() {
return g;
}
-// Sand-Aufschüttung: eine breite, flache Strandplattform (liegt quer zur
-// Küstenlinie — coastRotation richtet sie automatisch parallel aus) mit
-// einem kleinen Dünenhügel in der Mitte. Soll klar als künstliche
-// Strand-Erweiterung lesbar sein: sie ragt sichtbar ins Wasser.
+// Sand-Aufschüttung: glockenförmiger Sandhügel (Lathe, Cos-Profil). Ist
+// bewusst klein und zurückhaltend — wirkt als lokaler Dünenabschnitt am
+// Ufer und nicht als Podest. Parallel-Ausrichtung über coastRotation.
function makeSandFill() {
const g = new THREE.Group();
- const sandMat = new THREE.MeshStandardMaterial({ color: 0xf0d890, roughness: 1 });
- // Hauptplattform: lange Strandbank, 1.8×0.3×0.7 Einheiten
- const platformShape = new THREE.Shape();
- platformShape.moveTo(-0.9, -0.30);
- platformShape.lineTo( 0.9, -0.30);
- platformShape.lineTo( 1.05, -0.20);
- platformShape.lineTo( 1.05, 0.20);
- platformShape.lineTo( 0.9, 0.30);
- platformShape.lineTo(-0.9, 0.30);
- platformShape.lineTo(-1.05, 0.20);
- platformShape.lineTo(-1.05,-0.20);
- platformShape.closePath();
- const platformGeo = new THREE.ExtrudeGeometry(platformShape, { depth: 0.28, bevelEnabled: false });
- platformGeo.rotateX(-Math.PI / 2);
- const platform = new THREE.Mesh(platformGeo, sandMat);
- // leichte Höhen-Variationen für Natur-Look
- const pp = platformGeo.attributes.position;
- for (let i = 0; i < pp.count; i++) {
- const x = pp.getX(i), y = pp.getY(i), z = pp.getZ(i);
- if (y > 0.25) pp.setY(i, y + Math.sin(x * 2.3) * 0.025 + Math.cos(z * 2.1) * 0.02);
- }
- platformGeo.computeVertexNormals();
- g.add(platform);
-
- // Kleiner Dünenhügel oben auf der Plattform (Lathe → Glockenprofil)
const points = [];
- const segments = 10, radius = 0.55, height = 0.22;
+ const segments = 12, radius = 0.85, height = 0.42;
for (let i = 0; i <= segments; i++) {
const t = i / segments;
- points.push(new THREE.Vector2(radius * Math.cos(t * Math.PI / 2), t * height));
+ const r = radius * Math.cos(t * Math.PI / 2);
+ points.push(new THREE.Vector2(r, t * height));
}
- const moundGeo = new THREE.LatheGeometry(points, 16);
- const mp = moundGeo.attributes.position;
- for (let i = 0; i < mp.count; i++) {
- const x = mp.getX(i), y = mp.getY(i), z = mp.getZ(i);
- mp.setY(i, y + Math.sin(x * 5) * 0.012 + Math.cos(z * 4) * 0.01);
- }
- moundGeo.computeVertexNormals();
- const mound = new THREE.Mesh(moundGeo, sandMat);
- mound.position.y = 0.28; // sitzt auf der Plattform
- g.add(mound);
-
- // Ein paar Grashalme/Strandgras-Büschel als Akzent
- const grassMat = new THREE.MeshStandardMaterial({ color: 0xa8b060, roughness: 1 });
- for (const [gx, gz] of [[-0.6, 0.15], [0.5, -0.2], [-0.2, 0.22]]) {
- const grass = new THREE.Mesh(
- new THREE.ConeGeometry(0.06, 0.18, 5),
- grassMat
- );
- grass.position.set(gx, 0.37, gz); g.add(grass);
+ const geom = new THREE.LatheGeometry(points, 18);
+ const pos = geom.attributes.position;
+ for (let i = 0; i < pos.count; i++) {
+ const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
+ pos.setY(i, y + Math.sin(x * 5) * 0.015 + Math.cos(z * 4) * 0.012);
}
+ geom.computeVertexNormals();
+ const sand = new THREE.Mesh(
+ geom,
+ new THREE.MeshStandardMaterial({ color: 0xe6cf8a, roughness: 1 })
+ );
+ g.add(sand);
return g;
}
@@ -3061,6 +3064,51 @@ function coastRotation(x, z) {
);
}
+// Länge eines Radweg-Mesh-Asphalt-Bands (siehe makeBikePath: BoxGeometry 1.4)
+const BIKE_LENGTH = 1.4;
+const BIKE_SNAP_RADIUS = 2.2; // ab dieser Nähe zu bestehendem Radweg andocken
+
+/**
+ * snapBikesPlacement(rawX, rawZ) — wenn ein bestehender Radweg in der
+ * Nähe ist, gibt es die Position zurück, an der das neue Segment
+ * bündig an dessen Stirnseite andockt (gleiche Rotation, Abstand =
+ * BIKE_LENGTH entlang seiner Tangente, vor- oder rückwärts je nachdem
+ * wo der Cursor sitzt). Gibt null zurück, wenn nichts zum Snappen da.
+ */
+function snapBikesPlacement(rawX, rawZ) {
+ const bikes = state.ownedMeasures && state.ownedMeasures.bikes;
+ if (!bikes || !bikes.instances || !bikes.instances.length) return null;
+ let best = null, bestDist = Infinity;
+ for (const inst of bikes.instances) {
+ if (!inst.pos) continue;
+ const dx = rawX - inst.pos.x;
+ const dz = rawZ - inst.pos.z;
+ const d = Math.sqrt(dx*dx + dz*dz);
+ if (d < bestDist) { bestDist = d; best = inst; }
+ }
+ if (!best || bestDist > BIKE_SNAP_RADIUS) return null;
+
+ // Lokales +X des bestehenden Mesh in Welt (rotation.y um Y):
+ // (1,0,0) → (cos rot, 0, −sin rot)
+ const rot = best.rotation || 0;
+ const fx = Math.cos(rot);
+ const fz = -Math.sin(rot);
+ // Seite entscheiden: liegt rawPos vor oder hinter dem bestehenden Zentrum?
+ const relX = rawX - best.pos.x;
+ const relZ = rawZ - best.pos.z;
+ const dir = (relX * fx + relZ * fz) >= 0 ? 1 : -1;
+ // Leicht überlappender Abstand (~92 % der Mesh-Länge), damit die Segmente
+ // visuell ineinander greifen statt Lücken zu lassen.
+ const SPACING = BIKE_LENGTH * 0.92;
+ const snapX = best.pos.x + dir * SPACING * fx;
+ const snapZ = best.pos.z + dir * SPACING * fz;
+ const snapY = Math.max(terrainHeight(snapX, snapZ), 0);
+ // Rotation am NEUEN Mittelpunkt aus coastRotation — so folgt die Kette
+ // der Küstenbiegung, Glied für Glied leicht gedreht.
+ const newRot = coastRotation(snapX, snapZ);
+ return { x: snapX, y: snapY, z: snapZ, rotation: newRot };
+}
+
/**
* updateZoneOverlay(id) — setzt per-Vertex RGBA-Farben im zoneOverlay
* basierend auf isZoneValid. Valide Bereiche werden grün-translucent,
@@ -3189,19 +3237,27 @@ function updatePlacementGhost(clientX, clientY) {
return;
}
const hit = hits[0];
- const x = hit.point.x, z = hit.point.z;
+ let x = hit.point.x, z = hit.point.z;
// Höhe immer aus Terrain-Formel lesen, nicht vom Raycast-Hit
// (für Maßnahmen auf der Insel, nicht fürs offene Meer).
let y;
if (zone && zone.kind === 'sea') y = WATER_Y_BASE + 0.1;
else y = Math.max(terrainHeight(x, z), 0);
- placementGhost.position.set(x, y, z);
- placementPos.x = x; placementPos.y = y; placementPos.z = z;
- placementValid = isZoneValid(placementId, x, z, terrainHeight(x, z));
// Coast-Typen: Längsachse parallel zur Küste ausrichten. Andere Maßnahmen
// behalten die einmal bei startPlacement gewürfelte Rotation.
if (isCoastMeasure(placementId)) placementRotation = coastRotation(x, z);
+
+ // Bike-Snap: wenn ein bestehender Radweg in Reichweite liegt, dock das
+ // Ghost an dessen Stirnseite an (wie Schienen). Länge = BIKE_LENGTH.
+ if (placementId === 'bikes') {
+ const snap = snapBikesPlacement(x, z);
+ if (snap) { x = snap.x; z = snap.z; y = snap.y; placementRotation = snap.rotation; }
+ }
+
+ placementGhost.position.set(x, y, z);
+ placementPos.x = x; placementPos.y = y; placementPos.z = z;
+ placementValid = isZoneValid(placementId, x, z, terrainHeight(x, z));
placementGhost.rotation.y = placementRotation;
// Ghost-Farbe: grün = valide, rot = nicht valide (via emissive)
@@ -3940,6 +3996,55 @@ function syncMountainShield() {
}
}
+// --- Bürger-Event „Strand künstlich aufschütten" (citizen-tourism) ---
+// Setzt state.hasSandBeach = true. Wir zeigen dann vorne an der Insel eine
+// angehobene Strand-Schwelle als breiten, flachen Sand-Streifen parallel
+// zur Küste. Das ist kein einzelnes Mesh pro Kauf, sondern eine einmalige
+// Stadt-Investition (daher separat vom makeSandFill der Bau-Maßnahme).
+let citizenBeach = null;
+function buildCitizenBeach() {
+ const g = new THREE.Group();
+ g.userData.infoKey = 'sand_fill_info';
+ const sandMat = new THREE.MeshStandardMaterial({ color: 0xf0d890, roughness: 1 });
+ // Flacher, länglicher Strandwall vorne am Ufer
+ const shape = new THREE.Shape();
+ shape.moveTo(-3.5, -0.45);
+ shape.lineTo( 3.5, -0.45);
+ shape.lineTo( 3.9, -0.25);
+ shape.lineTo( 3.9, 0.25);
+ shape.lineTo( 3.5, 0.45);
+ shape.lineTo(-3.5, 0.45);
+ shape.lineTo(-3.9, 0.25);
+ shape.lineTo(-3.9, -0.25);
+ shape.closePath();
+ const geo = new THREE.ExtrudeGeometry(shape, { depth: 0.28, bevelEnabled: false });
+ geo.rotateX(-Math.PI / 2);
+ // Leichte sin-Wellen oben auf dem Deckel
+ const pp = geo.attributes.position;
+ for (let i = 0; i < pp.count; i++) {
+ const x = pp.getX(i), y = pp.getY(i), z = pp.getZ(i);
+ if (y > 0.26) pp.setY(i, y + Math.sin(x * 1.3) * 0.02 + Math.cos(z * 2.0) * 0.015);
+ }
+ geo.computeVertexNormals();
+ const strand = new THREE.Mesh(geo, sandMat);
+ // An den vorderen Küstenrand pflanzen, parallel zur Küste dort (+Z-Seite)
+ strand.position.set(0, Math.max(terrainHeight(0, ISLAND_HALF_LENGTH * 0.86), 0), ISLAND_HALF_LENGTH * 0.86);
+ // Tangente an Ellipse vorn (x=0, z=+ISLAND_HALF_LENGTH*0.86) ist entlang ±X
+ strand.rotation.y = coastRotation(0, ISLAND_HALF_LENGTH * 0.86);
+ g.add(strand);
+ return g;
+}
+function syncCitizenBeach() {
+ const want = !!state.hasSandBeach;
+ if (want && !citizenBeach) {
+ citizenBeach = buildCitizenBeach();
+ scene.add(citizenBeach);
+ } else if (!want && citizenBeach) {
+ scene.remove(citizenBeach);
+ citizenBeach = null;
+ }
+}
+
// Zentrale Ambient-Update-Funktion (aus sceneRender pro Frame)
function updateAmbient(tNow, dt) {
updateBoats(tNow);
@@ -4078,6 +4183,24 @@ function startGame(levelId) {
tutorialStep: 0, tutorialActive: false,
pendingCitizenEvent: null,
});
+ _wasBudgetCritical = false;
+ _wasTempCritical = false;
+ _wasFloodCritical = false;
+
+ // --- Start-Wald: drei Bäume als Anfangsbestand. Bei Stromausfall
+ // fällt die Engine pro Jahr `treesPerBlackout` Bäume (engine.js L611+),
+ // popt sie aus instances und reduziert count — die Meshes
+ // verschwinden entsprechend aus der Szene (syncMeasureMeshes).
+ // Die drei Start-Bäume sind nicht im actionLog (kein Player-Kauf),
+ // zählen aber beim `forester`-Achievement mit.
+ const forestSeed = { count: 0, instances: [] };
+ for (let i = 0; i < 3; i++) {
+ const meta = pickBuildPosition('forest');
+ forestSeed.instances.push({ builtAt: 0, starter: true, ...meta });
+ forestSeed.count++;
+ }
+ state.ownedMeasures.forest = forestSeed;
+ KlimaEngine.recalcEffects(state); // co2Reduction etc. mit Seed-Wald neu aufsummieren
// --- 2. UI-Caches ---
_prevAchHash = '';
@@ -4114,8 +4237,15 @@ function startGame(levelId) {
// hasMountainShield in createGame nicht gesetzt → false/undefined).
buildVillage();
syncMountainShield();
+ syncCitizenBeach();
+ syncMeasureMeshes(); // die drei Start-Bäume sichtbar machen
renderUI();
startTutorial();
+ // Hinweis-Card: erklärt die Start-Bäume + Blackout-Mechanik. infoKey
+ // 'blackout' macht sie klickbar → INFO_TOPICS.blackout-Detailseite.
+ setTimeout(() => {
+ showEvent('🌲', 'Du startest mit 3 Bäumen. Bei Stromausfall werden sie als Brennholz gefällt — baue rechtzeitig Wind oder Solar.', 'neutral', 'blackout');
+ }, 900);
}
function endLevel(won, reason) {