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) {