Klima 3D: Radweg-Snap, Start-Bäume, Krisen-Toasts, Airport-Plateau, Sand-Fixes

- Radwege: snappen an bestehende Pfade an (Distanz 2.2, Spacing 92 %,
  leicht überlappend). Rotation am NEUEN Mittelpunkt via coastRotation,
  Kette folgt so der Küstenbiegung statt starre Linie.
- Start-Wald: 3 Bäume bei startGame pre-seeded (forest.instances mit
  starter-Flag). Engine-Blackout-Logik fällt sie bei Stromausfall.
  Toast 'Du startest mit 3 Bäumen ...' am Level-Start, klickbar auf
  INFO_TOPICS.blackout.
- updateCrisisMusic: Edge-Detection pro Kriterium (Budget/Temp/Flood),
  jeweils Toast-Card beim Übergang ins Kritische. Merker in startGame
  zurückgesetzt.
- Airport: Plateau-Sockel (3.4×0.20×1.55 + dünne helle Oberkante), alle
  Bauten um LIFT=0.24 angehoben. Hebt Airport sichtbar aus dem Terrain,
  kein Versinken am Hang mehr. Heli-Pad-Offset folgt nach.
- Sand-Aufschüttung (Bau-Maßnahme): zurück zum reinen Lathe-Kegel
  (Plattform + Gras raus). Kleiner, zurückhaltender Sand-Hügel wie
  ursprünglich V1.
- Citizen-Event 'Strand aufschütten' (citizen-tourism): neuer
  syncCitizenBeach → breiter flacher Sand-Streifen vorne am Ufer,
  parallel zur Küste. Nur sichtbar wenn state.hasSandBeach gesetzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 13:53:48 +02:00
parent 64bb9356b0
commit 3836b29c5d
+202 -72
View File
@@ -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) {