/**
 * Klimawächter 3D — Three.js Renderer
 *
 * Vollbild-3D-Ansicht der Insel-Szene. Verwendet dieselbe KlimawaechterGame-
 * Logik wie die 2D-Version, rendert aber als interaktive 3D-Welt mit
 * Drohnen-Kamera, Maus-Steuerung und sichtbarem Meeresspiegel-Anstieg.
 *
 * Designprinzipien:
 * - Flat-Shaded Skandi-Look, keine Reflexionen/PBR
 * - Insel ist absichtlich sehr flach, damit Meeresspiegelanstieg sichtbar wird
 * - Drohnenflug: kontinuierliche, langsame Kreisbewegung um die Insel
 * - Maus-Drag rotiert manuell, Scrollrad zoomt, Buttons über Methoden
 * - Deiche/Mauern werden ans Ufer gesetzt, andere Maßnahmen ins Inselinnere
 * - Pre-existierende Dorfhäuser repräsentieren die Startbevölkerung
 */

import * as THREE from 'three'
import type { KlimawaechterGame } from '../sim-05-treibhaus/game'

interface Placed {
  ownerId: string
  mesh: THREE.Object3D
  typeId: string
}

/**
 * Einzelne kleine Rauchwolke über einem Haus.
 * Steigt auf, wird vom Wind etwas zur Seite gedrückt, löst sich auf
 * und spawnt dann wieder unten am Schornstein.
 */
interface SmokePuff {
  mesh: THREE.Mesh
  lifetime: number     // Lebensdauer in Sekunden
  age: number          // aktuelles Alter
  speedY: number       // wie schnell es steigt
  windAmp: number      // individuelle X-Drift-Amplitude
  windPhase: number    // individuelles Timing
  baseRadius: number   // Puff-Größe
}

/**
 * Generischer FX-Effekt (Totenkopf-Sprite, Flucht-Boot, …)
 * Der Renderer hält eine Liste davon und ruft update() pro Frame auf.
 * Gibt update() false zurück, wird der Effekt aus der Liste entfernt.
 */
interface FxEffect {
  root: THREE.Object3D
  lifetime: number
  age: number
  update: (dt: number, effect: FxEffect) => boolean
}

// === Insel-Geometrie-Konstanten ===
// Die Insel ist ein LÄNGLICHES Terrain, das vom Meer sanft ansteigt.
// Am hinteren Ende erhebt sich ein Vulkan, der AUS DEM TERRAIN wächst (kein
// separater Aufsatz mehr).
const ISLAND_HALF_WIDTH = 13     // Breite (X-Richtung, ± davon)
const ISLAND_HALF_LENGTH = 17    // Länge (Z-Richtung, ± davon)
const ISLAND_HIGH_EDGE_Y = 1.9   // Grundhöhe am hinteren Rand (Vulkanseite)
const ISLAND_LOW_EDGE_Y = -0.25  // Vorderkante unter Wasser
const WATER_Y_BASE = 0.0
const MAX_SEA_RISE_UNITS = 1.6   // 200 cm sealevel → komplett über Low-Edge

const INNER_RADIUS_MAX = 6.5     // Maßnahmen-Bereich grob

// Vulkan — wird als Gauss-Erhebung ins Terrain eingebacken,
// darüber sitzt eine Kuppel als Gipfel (statt eines Cylinders).
const VOLCANO_X = -3.0
const VOLCANO_Z = -11.0
const VOLCANO_BASE_RADIUS = 5.5   // Gauss-Sigma für die Terrain-Erhebung
const VOLCANO_BUMP_HEIGHT = 3.6   // Gauss-Höhe (Terrain-Komponente)
// Kuppel als Gipfel (sitzt direkt auf der Terrain-Erhebung)
// +30 % höher als vorher, etwas schmaler, Basis wird in den Terrain-Boden versenkt
const VOLCANO_DOME_HEIGHT = 2.1          // 1.6 → 2.1 (+30 %)
const VOLCANO_DOME_RADIUS = 2.2
const VOLCANO_DOME_SINK = 0.45           // wie tief die Basis IM Terrain steckt
// Schnee ist ein Klon der Kuppel, minimal nach außen verschoben,
// weiß eingefärbt, und wird per clippingPlane von unten weggeschnitten.
const SNOW_OFFSET = 0.03                 // radial/axial-Offset zur Vulkan-Kuppel

// === Höhen-Zonen für Vertex-Farben ===
// Unter Wasser → Strand → Wiese → Wald → Fels
// Strand deutlich breiter als vorher, weil er DAS didaktische Element ist
const ZONE_BEACH_END   = 0.38    // breiter Strand
const ZONE_MEADOW_END  = 0.95    // bis hier helle Wiese
const ZONE_FOREST_END  = 1.80    // bis hier dunklerer Wald
// darüber: Fels

// Helpers
function smoothstep(edge0: number, edge1: number, x: number): number {
  const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)))
  return t * t * (3 - 2 * t)
}
function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t
}

export class KlimawaechterRenderer3D {
  private scene: THREE.Scene
  private camera: THREE.PerspectiveCamera
  private renderer: THREE.WebGLRenderer
  private game: KlimawaechterGame
  private container: HTMLElement
  private placed: Placed[] = []
  private knownIds = new Set<string>()
  private animId = 0
  private t = 0

  // Szenen-Elemente
  private water!: THREE.Mesh
  private sky!: THREE.Mesh
  private island!: THREE.Mesh
  private volcanoRock!: THREE.Mesh        // Berg-Kegel (kahler Fels)
  private glacierIce!: THREE.Mesh         // Eis-/Schnee-Kappe (Klon der Kuppel)
  private glacierSnow!: THREE.Mesh        // (alt, ungenutzt)
  private glacierBaseScale = 1.0
  /** Clipping-Plane für die Schmelze — wandert mit Temperatur hoch */
  private snowClipPlane!: THREE.Plane
  /** Welt-Y der Schnee-Basis, damit wir die Clip-Konstante korrekt setzen */
  private snowBaseWorldY = 0
  private villageGroup!: THREE.Group
  private smokeGroup!: THREE.Group
  // Pro Haus ein eigener Puff-Satz (kleine Wölkchen mit individuellen Timings)
  private smokePuffs: SmokePuff[][] = []
  private houseAnchors: THREE.Vector3[] = []
  private trees: Array<{
    mesh: THREE.Object3D
    kind: 'palm' | 'leaf' | 'conifer'
    anchorY: number
    stage: 'alive' | 'dying' | 'dead'  // grün → braun → schwarzer Strunk
  }> = []
  /** Schwebende FX-Effekte (Totenköpfe, Boote, ...) */
  private fxEffects: FxEffect[] = []
  private fxGroup!: THREE.Group
  /** Statischer Lawinen-Schutzwall am Vulkanfuß (einmalig, wenn vom Bürger-Event gewählt) */
  private mountainShieldGroup: THREE.Group | null = null
  /** Steg am vorderen Strand — Bewohner warten dort, Boote legen dort an */
  private pierGroup!: THREE.Group
  /** Wartende Bewohner-Sprites am Steg (immer sichtbar) */
  private waitingFigures: THREE.Mesh[] = []
  /** Insel-Leben: Wolken-Sprites am Himmel (werden bei Hitze dunkler) */
  private ambientClouds: THREE.Mesh[] = []
  /** Möwen am Strand — verschwinden ab Temperatur >17°C nacheinander */
  private ambientGulls: THREE.Mesh[] = []
  /** Statisches ankerndes Fischerboot in der Bucht — verschwindet wenn Tourismus aktiv */
  private ambientFishingBoat: THREE.Group | null = null
  /** Position der Steg-Spitze (Bootsanlegestelle) — wird in buildPier gesetzt */
  private pierEndPos = new THREE.Vector3(0, 0, 0)
  /** Letzter bekannter Bevölkerungs-Wert (für Sprung-Erkennung) */
  private lastPopulation = 10000
  /** Wie viele Bäume bisher wegen Stromausfall gefällt wurden (Renderer-Sicht) */
  private lastChoppedRendered = 0

  // === Baueditor / Placement-Mode ===
  /** Aktuell zu platzierende Maßnahme (null = kein Placement-Mode) */
  private placementMeasureId: string | null = null
  /** Ghost-Mesh, das mit dem Mauszeiger mitwandert */
  private placementGhost: THREE.Object3D | null = null
  /** Materialien des Ghost-Meshes (für Farb-Update grün/rot) */
  private placementGhostMats: THREE.MeshStandardMaterial[] = []
  /** Aktuelle Welt-Position des Ghosts */
  private placementCurrentPos = { x: 0, z: 0 }
  /** Ist die aktuelle Position gültig? */
  private placementCurrentValid = false
  /** Raycaster für Maus → Welt-Koordinaten */
  private placementRaycaster = new THREE.Raycaster()
  private placementMouseNDC = new THREE.Vector2()
  /** Callback ans HTML-Script, wenn Placement bestätigt wird */
  private placementOnConfirm: ((id: string, x: number, z: number) => void) | null = null
  /** Callback ans HTML-Script, wenn Placement abgebrochen wird */
  private placementOnCancel: (() => void) | null = null
  private resizeHandler: () => void

  // Kamera-Steuerung (sphärische Koordinaten um das Inselzentrum)
  private camTheta = Math.PI * 0.12
  private camPhi = Math.PI * 0.34       // flacher Blick für den Keil
  private camRadius = 30                // passend zur größeren Insel
  private droneActive = true
  private droneResumeAt = 0
  private isDragging = false
  private lastPointerX = 0
  private lastPointerY = 0
  private pointerDownHandler: (e: PointerEvent) => void
  private pointerMoveHandler: (e: PointerEvent) => void
  private pointerUpHandler: (e: PointerEvent) => void
  private wheelHandler: (e: WheelEvent) => void

  constructor(container: HTMLElement, game: KlimawaechterGame) {
    this.container = container
    this.game = game

    this.scene = new THREE.Scene()
    this.scene.background = new THREE.Color('#bcd5e6')

    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false })
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    this.renderer.shadowMap.enabled = true
    // PCFShadowMap ist günstiger als die Soft-Variante
    this.renderer.shadowMap.type = THREE.BasicShadowMap

    const { width, height } = this.measure()
    this.renderer.setSize(width, height)
    this.renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;'
    container.appendChild(this.renderer.domElement)

    this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 600)
    this.applyCamera()

    // === Licht ===
    const ambient = new THREE.AmbientLight(0xfff0d8, 0.7)
    this.scene.add(ambient)
    const sun = new THREE.DirectionalLight(0xffe8c0, 1.1)
    sun.position.set(20, 28, 12)
    sun.castShadow = true
    // Schatten-Map klein halten — spart deutlich GPU-Last
    sun.shadow.mapSize.set(512, 512)
    sun.shadow.camera.left = -30
    sun.shadow.camera.right = 30
    sun.shadow.camera.top = 30
    sun.shadow.camera.bottom = -30
    sun.shadow.camera.near = 0.1
    sun.shadow.camera.far = 100
    this.scene.add(sun)

    // === Himmel ===
    const skyGeom = new THREE.SphereGeometry(300, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2)
    const skyMat = new THREE.MeshBasicMaterial({ color: 0xbcd5e6, side: THREE.BackSide })
    this.sky = new THREE.Mesh(skyGeom, skyMat)
    this.scene.add(this.sky)

    // Sonne (am Himmel — nur deko)
    const sunSphere = new THREE.Mesh(
      new THREE.SphereGeometry(2.5, 24, 16),
      new THREE.MeshBasicMaterial({ color: 0xffe4a0 }),
    )
    sunSphere.position.set(28, 36, -18)
    this.scene.add(sunSphere)

    // === Meer ===
    const waterGeom = new THREE.PlaneGeometry(400, 400, 1, 1)
    const waterMat = new THREE.MeshStandardMaterial({
      color: 0x2a78b8,
      roughness: 0.55,
      metalness: 0.15,
    })
    this.water = new THREE.Mesh(waterGeom, waterMat)
    this.water.rotation.x = -Math.PI / 2
    this.water.position.y = WATER_Y_BASE
    this.water.receiveShadow = true
    this.scene.add(this.water)

    // === Insel als großes Terrain mit integriertem Vulkan ===
    // Statt separater Cylinder + Plane bauen wir EIN Höhenfeld. Der Vulkan
    // wächst aus dem Terrain als Gauss-Erhebung. Vertex-Farben markieren
    // Strand / Wiese / Wald / Fels-Zonen.
    const segX = 90
    const segZ = 120
    const islandGeom = new THREE.PlaneGeometry(
      ISLAND_HALF_WIDTH * 2.4,
      ISLAND_HALF_LENGTH * 2.4,
      segX,
      segZ,
    )
    islandGeom.rotateX(-Math.PI / 2)
    const pos = islandGeom.attributes.position as THREE.BufferAttribute
    const colorArr = new Float32Array(pos.count * 3)
    for (let i = 0; i < pos.count; i++) {
      const x = pos.getX(i)
      const z = pos.getZ(i)
      const y = this.terrainHeight(x, z)
      pos.setY(i, y)
      // Vertex-Farbe nach Höhe bestimmen
      const c = this.zoneColor(y)
      colorArr[i * 3]     = c[0]
      colorArr[i * 3 + 1] = c[1]
      colorArr[i * 3 + 2] = c[2]
    }
    islandGeom.setAttribute('color', new THREE.BufferAttribute(colorArr, 3))
    islandGeom.computeVertexNormals()

    const islandMat = new THREE.MeshStandardMaterial({
      vertexColors: true,
      roughness: 1,
      flatShading: false,
    })
    this.island = new THREE.Mesh(islandGeom, islandMat)
    this.island.receiveShadow = true
    this.island.castShadow = true
    this.scene.add(this.island)
    // Referenz-Kopie für Farbe-Update (Klimastress färbt später)
    ;(this.island as any).baseColors = new Float32Array(colorArr)

    // === Vulkan + Gletscher (am Rand der Insel) ===
    this.buildVolcano()

    // === Pre-existierendes Dorf ===
    this.villageGroup = new THREE.Group()
    this.scene.add(this.villageGroup)
    this.smokeGroup = new THREE.Group()
    this.scene.add(this.smokeGroup)
    this.fxGroup = new THREE.Group()
    this.scene.add(this.fxGroup)
    this.buildStartingVillage()
    this.buildSmokePlumes()
    this.buildPier()
    this.buildAmbientLife()
    // Initiale Bevölkerung merken
    this.lastPopulation = this.game.getResource('population')

    // === Resize ===
    this.resizeHandler = () => this.onResize()
    window.addEventListener('resize', this.resizeHandler)

    // === Maus-Steuerung ===
    this.pointerDownHandler = (e: PointerEvent) => this.onPointerDown(e)
    this.pointerMoveHandler = (e: PointerEvent) => this.onPointerMove(e)
    this.pointerUpHandler = () => this.onPointerUp()
    this.wheelHandler = (e: WheelEvent) => this.onWheel(e)
    const dom = this.renderer.domElement
    dom.style.cursor = 'grab'
    dom.style.touchAction = 'none'
    dom.addEventListener('pointerdown', this.pointerDownHandler)
    window.addEventListener('pointermove', this.pointerMoveHandler)
    window.addEventListener('pointerup', this.pointerUpHandler)
    dom.addEventListener('wheel', this.wheelHandler, { passive: false })
    // Im Placement-Mode: Browser-Kontextmenü auf Rechtsklick unterdrücken
    dom.addEventListener('contextmenu', (e) => {
      if (this.placementMeasureId) e.preventDefault()
    })
    // Tastatur:
    //   - ESC: Placement-Mode beenden
    //   - Pfeiltasten links/rechts: Insel manuell drehen
    //   - Pfeiltasten oben/unten: Kamera anheben/senken
    window.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && this.placementMeasureId) {
        this.cancelPlacement()
        return
      }
      const step = 0.08
      if (e.key === 'ArrowLeft')  { this.camTheta -= step; this.pauseDrone() }
      if (e.key === 'ArrowRight') { this.camTheta += step; this.pauseDrone() }
      if (e.key === 'ArrowUp')    { this.camPhi = Math.max(0.10, this.camPhi - step * 0.6); this.pauseDrone() }
      if (e.key === 'ArrowDown')  { this.camPhi = Math.min(Math.PI * 0.48, this.camPhi + step * 0.6); this.pauseDrone() }
    })
  }

  // ============================================================
  // Public API für UI-Buttons
  // ============================================================

  zoomIn(): void {
    this.camRadius = Math.max(14, this.camRadius - 4)
    this.pauseDrone()
  }

  zoomOut(): void {
    this.camRadius = Math.min(70, this.camRadius + 4)
    this.pauseDrone()
  }

  recenter(): void {
    this.camTheta = Math.PI * 0.12
    this.camPhi = Math.PI * 0.34
    this.camRadius = 30
    this.droneActive = true
  }

  // ============================================================
  // Baueditor — Public API
  // ============================================================

  /**
   * Startet den Placement-Mode für eine Maßnahme. Erzeugt ein Ghost-Mesh,
   * das der Maus folgt. Klick → onConfirm(id, x, z). ESC oder rechter
   * Mausklick → onCancel().
   */
  startPlacement(
    measureId: string,
    onConfirm: (id: string, x: number, z: number) => void,
    onCancel: () => void,
  ): void {
    if (this.placementMeasureId) this.cancelPlacement()
    this.placementMeasureId = measureId
    this.placementOnConfirm = onConfirm
    this.placementOnCancel = onCancel
    // Während des Baumodus: Drohnen-Drehung anhalten, damit der Spieler
    // ruhig zielen kann. Mit Pfeiltasten kann manuell gedreht werden.
    this.droneActive = false
    this.droneResumeAt = Number.POSITIVE_INFINITY

    // Ghost-Mesh erzeugen — wir nutzen die existierenden Make-Helper,
    // klonen das Mesh und tauschen alle Materialien gegen halbtransparente.
    const ghost = this.makeMeasureMeshFor(measureId, 0)
    if (!ghost) {
      this.placementMeasureId = null
      return
    }
    this.placementGhostMats = []
    ghost.traverse((obj) => {
      const mesh = obj as THREE.Mesh
      if (mesh.isMesh && mesh.material) {
        // Original-Material klonen, transparent machen
        const orig = mesh.material as THREE.MeshStandardMaterial
        const clone = orig.clone()
        clone.transparent = true
        clone.opacity = 0.55
        clone.depthWrite = false
        mesh.material = clone
        this.placementGhostMats.push(clone)
      }
    })
    this.placementGhost = ghost
    this.scene.add(ghost)
    // Position initial außerhalb der Sicht
    ghost.position.set(0, -100, 0)
    this.renderer.domElement.style.cursor = 'crosshair'
  }

  cancelPlacement(): void {
    if (!this.placementMeasureId) return
    const cb = this.placementOnCancel
    this.placementMeasureId = null
    if (this.placementGhost) {
      this.scene.remove(this.placementGhost)
      this.placementGhost.traverse((obj) => {
        const m = (obj as THREE.Mesh)
        if (m.geometry) m.geometry.dispose?.()
      })
      this.placementGhostMats.forEach(m => m.dispose())
      this.placementGhostMats = []
      this.placementGhost = null
    }
    this.placementOnConfirm = null
    this.placementOnCancel = null
    this.renderer.domElement.style.cursor = 'grab'
    // Drohne wieder aktivieren (mit kurzer Pause, damit der Spieler die
    // letzte Stellung erstmal sehen kann)
    this.droneResumeAt = this.t + 4
    if (cb) cb()
  }

  /** Wird intern aufgerufen, wenn eine Maus-Bewegung im Placement-Mode passiert. */
  private updatePlacementGhost(clientX: number, clientY: number): void {
    if (!this.placementMeasureId || !this.placementGhost) return
    const id = this.placementMeasureId
    const isShoreObject = id === 'dike' || id === 'sea-wall' || id === 'mangrove'
    const isSand = id === 'sand-fill'

    // Maus → NDC
    const rect = this.renderer.domElement.getBoundingClientRect()
    this.placementMouseNDC.x = ((clientX - rect.left) / rect.width) * 2 - 1
    this.placementMouseNDC.y = -((clientY - rect.top) / rect.height) * 2 + 1
    this.placementRaycaster.setFromCamera(this.placementMouseNDC, this.camera)

    // Raycast: für Sand-Aufschüttung gegen INSEL UND WASSER, sodass auch
    // Klicks aufs offene Meer als Position verwendet werden können.
    // Für alle anderen Maßnahmen reicht die Insel.
    const targets: THREE.Object3D[] = isSand
      ? [this.island, this.water]
      : [this.island]
    const hits = this.placementRaycaster.intersectObjects(targets, false)
    if (hits.length === 0) {
      this.placementGhost.position.y = -100
      this.placementCurrentValid = false
      return
    }
    // Nimm den nächsten Treffer (Wasser oder Insel — das, was näher zur Kamera ist)
    const hit = hits[0]
    const x = hit.point.x
    const z = hit.point.z
    const y = this.terrainHeight(x, z)
    this.placementCurrentPos.x = x
    this.placementCurrentPos.z = z

    // Validität prüfen
    let valid = true
    let reason = ''
    if (this.isInVolcanoArea(x, z, 0.5)) {
      valid = false
      reason = 'Vulkan-Bereich'
    } else if (isSand) {
      // Sand: KOMPLETT frei. Egal wo der Spieler klickt — solange es nicht
      // mitten im Vulkan ist, wird ein Hügel platziert. Auch keine Höhen-
      // beschränkung mehr — der Spieler darf sogar auf der Wiese aufschütten.
    } else if (isShoreObject) {
      if (y < 0.05 || y > 0.45) { valid = false; reason = 'nicht am Strand' }
    } else {
      if (y < 0.40) { valid = false; reason = 'zu nah am Wasser' }
      if (y > 1.7)  { valid = false; reason = 'auf dem Felsen' }
    }
    // Mindestabstand zu existierenden Maßnahmen — bei Sand komplett ignoriert,
    // damit Hügel direkt nebeneinander aufgeschüttet werden können.
    if (valid && !isSand) {
      const minDist2 = 1.0
      for (const p of this.placed) {
        const dx = p.mesh.position.x - x
        const dz = p.mesh.position.z - z
        if (dx * dx + dz * dz < minDist2) { valid = false; reason = 'zu nah an einem Bauwerk'; break }
      }
    }
    this.placementCurrentValid = valid

    // Ghost positionieren — bei Sand auf den Wasserspiegel/Boden (je nachdem
    // was höher ist), damit der Hügel auch sichtbar aus dem Wasser ragt
    if (isSand) {
      const ghostY = Math.max(this.water.position.y - 0.05, y)
      this.placementGhost.position.set(x, ghostY, z)
    } else {
      this.placementGhost.position.set(x, Math.max(0, y) + 0.02, z)
    }

    // Farbe des Ghosts grün/rot tönen
    const tintR = valid ? 0.55 : 1.0
    const tintG = valid ? 1.0 : 0.45
    const tintB = valid ? 0.55 : 0.45
    for (const mat of this.placementGhostMats) {
      // Wir multiplizieren das Material color mit dem Tint, indem wir
      // emissive setzen. Einfacher: color setzen und merken.
      mat.color.setRGB(tintR, tintG, tintB)
    }
  }

  /**
   * Wird beim Klick im Placement-Mode aufgerufen.
   *
   * MEHRFACH-BAU: Nach dem Bestätigen bleibt der Placement-Mode aktiv —
   * der Ghost folgt weiter dem Mauszeiger, und der nächste Klick baut
   * eine weitere Instanz. Beendet wird der Mode nur durch:
   *   - ESC-Taste
   *   - Rechtsklick
   *   - externes cancelPlacement() (z. B. wenn das Budget nicht mehr reicht)
   *
   * Der HTML-Callback (onConfirm) prüft nach jedem Bau, ob noch genug
   * Budget für eine weitere Einheit da ist, und ruft sonst cancelPlacement.
   */
  private confirmPlacement(): void {
    if (!this.placementMeasureId) return

    // Ungueltige Platzierung → Blubb-Effekt im Wasser zeigen
    if (!this.placementCurrentValid) {
      const { x, z } = this.placementCurrentPos
      const y = this.terrainHeight(x, z)
      if (y < 0.15) {
        // Im Wasser: Blubb-Blasen-Effekt
        this.spawnWaterBubble(x, z)
      }
      return
    }

    const id = this.placementMeasureId
    const { x, z } = this.placementCurrentPos
    const cb = this.placementOnConfirm
    // Wichtig: Aktuelle Position als ungültig markieren, damit ein direkter
    // zweiter Klick auf dieselbe Stelle nicht doppelt baut. Erst nach
    // einer Mausbewegung (updatePlacementGhost) wird wieder validiert.
    this.placementCurrentValid = false
    if (cb) cb(id, x, z)
    // KEIN Cleanup — Placement-Mode bleibt aktiv für Mehrfach-Bau.
  }

  /** Blubb-Blasen im Wasser — visuelles Feedback wenn man ins Wasser klickt */
  private spawnWaterBubble(x: number, z: number): void {
    const waterY = this.water.position.y
    for (let i = 0; i < 5; i++) {
      const geom = new THREE.SphereGeometry(0.04 + Math.random() * 0.06, 8, 6)
      const mat = new THREE.MeshStandardMaterial({
        color: 0xaaddff,
        transparent: true,
        opacity: 0.6,
        roughness: 0.1,
        metalness: 0.3,
      })
      const bubble = new THREE.Mesh(geom, mat)
      const bx = x + (Math.random() - 0.5) * 0.3
      const bz = z + (Math.random() - 0.5) * 0.3
      bubble.position.set(bx, waterY, bz)
      this.scene.add(bubble)

      // Animation: Blasen steigen auf und verschwinden
      const startTime = performance.now()
      const speed = 0.3 + Math.random() * 0.4
      const lifetime = 800 + Math.random() * 600
      const animate = () => {
        const elapsed = performance.now() - startTime
        if (elapsed > lifetime) {
          this.scene.remove(bubble)
          geom.dispose()
          mat.dispose()
          return
        }
        const t = elapsed / lifetime
        bubble.position.y = waterY + t * speed
        mat.opacity = 0.6 * (1 - t)
        bubble.scale.setScalar(1 + t * 0.5)
        requestAnimationFrame(animate)
      }
      // Leicht versetzt starten
      setTimeout(animate, i * 100)
    }
  }

  /**
   * Hilfs-Routine: erzeugt ein Mesh für die gegebene Maßnahme OHNE es in
   * die Szene einzufügen. Nutzt dieselben Make-Helper wie placeMeasure.
   */
  private makeMeasureMeshFor(typeId: string, index: number): THREE.Object3D | null {
    const angle = 0
    let obj: THREE.Object3D | null = null
    switch (typeId) {
      case 'forest':      obj = this.makeTree(); break
      case 'solar':       obj = this.makeSolarPanel(); break
      case 'wind':        obj = this.makeWindTurbine(); break
      case 'green-roof':  obj = this.makeGreenRoof(); break
      case 'dike':        obj = this.makeDike(angle); break
      case 'sea-wall':    obj = this.makeSeaWall(angle); break
      case 'coal':        obj = this.makeCoalPlant(); break
      case 'airport':     obj = this.makeAirport(); break
      case 'cloud-seed':  obj = this.makeCloudSeeder(); break
      case 'bikes':       obj = this.makeBikePath(); break
      case 'mangrove':    obj = this.makeMangrove(angle); break
      case 'sand-fill':   obj = this.makeSandFill(angle); break
    }
    return obj
  }

  // ============================================================
  // Maus-Steuerung
  // ============================================================

  private onPointerDown(e: PointerEvent): void {
    // Im Placement-Mode: Linksklick = bestätigen, Rechtsklick = abbrechen
    if (this.placementMeasureId) {
      if (e.button === 2) {
        this.cancelPlacement()
        return
      }
      if (e.button === 0) {
        this.confirmPlacement()
        return
      }
      return
    }
    if (e.button !== 0) return
    this.isDragging = true
    this.lastPointerX = e.clientX
    this.lastPointerY = e.clientY
    this.renderer.domElement.style.cursor = 'grabbing'
    this.pauseDrone()
  }

  private onPointerMove(e: PointerEvent): void {
    // Im Placement-Mode: Ghost-Mesh nachführen (egal ob Drag oder nicht)
    if (this.placementMeasureId) {
      this.updatePlacementGhost(e.clientX, e.clientY)
      return
    }
    if (!this.isDragging) return
    const dx = e.clientX - this.lastPointerX
    const dy = e.clientY - this.lastPointerY
    this.lastPointerX = e.clientX
    this.lastPointerY = e.clientY
    this.camTheta -= dx * 0.005
    this.camPhi = Math.max(0.10, Math.min(Math.PI * 0.48, this.camPhi - dy * 0.005))
  }

  private onPointerUp(): void {
    if (this.placementMeasureId) return  // Click wird in PointerDown behandelt
    if (!this.isDragging) return
    this.isDragging = false
    this.renderer.domElement.style.cursor = 'grab'
    this.pauseDrone()
  }

  private onWheel(e: WheelEvent): void {
    e.preventDefault()
    this.camRadius = Math.max(14, Math.min(70, this.camRadius + e.deltaY * 0.02))
    this.pauseDrone()
  }

  private pauseDrone(): void {
    this.droneActive = false
    this.droneResumeAt = this.t + 4   // nach 4 s ohne Aktivität wieder Drone-Mode
  }

  // ============================================================
  // Vorgefertigtes Dorf
  // ============================================================

  /**
   * Baut den Vulkan-Gipfel als Kuppel. Sitzt FEST im Terrain-Peak
   * (Basis ist um VOLCANO_DOME_SINK ins Terrain versenkt, damit kein Schwebe-Effekt).
   *
   * Der Schnee ist ein KLON der Kuppelgeometrie, leicht aufgeblasen und weiß.
   * Die Schmelze wird über eine Clipping-Plane realisiert, die mit steigender
   * Temperatur von unten nach oben durch den Schnee wandert — alles unterhalb
   * der Ebene wird abgeschnitten, Rest bleibt opak und korrekt beschattet.
   */
  private buildVolcano(): void {
    // Terrain-Höhe direkt am Vulkanzentrum
    const terrainPeakY = this.terrainHeight(VOLCANO_X, VOLCANO_Z)
    // Basis der Kuppel sitzt VOLCANO_DOME_SINK UNTER dem Terrain-Peak.
    // Dadurch wirkt die Kuppel gewachsen, nicht aufgesetzt.
    const domeBaseY = terrainPeakY - VOLCANO_DOME_SINK

    // === Kuppel als Lathe-Geometry ===
    const domePoints: THREE.Vector2[] = []
    const segments = 14
    for (let i = 0; i <= segments; i++) {
      const t = i / segments          // 0..1 von Basis zur Spitze
      // Glockenform durch cos-Profil (außen bei t=0, auf null bei t=1)
      const r = VOLCANO_DOME_RADIUS * Math.cos(t * Math.PI / 2)
      const y = t * VOLCANO_DOME_HEIGHT
      domePoints.push(new THREE.Vector2(r, y))
    }
    const domeGeom = new THREE.LatheGeometry(domePoints, 28)
    // Leichte Verformung für Felsstruktur
    const domePos = domeGeom.attributes.position as THREE.BufferAttribute
    for (let i = 0; i < domePos.count; i++) {
      const x = domePos.getX(i), y = domePos.getY(i), z = domePos.getZ(i)
      const r = Math.sqrt(x * x + z * z)
      if (r > 0.01) {
        const noise = Math.sin(x * 1.4 + y * 0.7) * 0.07 + Math.cos(z * 1.3) * 0.05
        domePos.setX(i, x + (x / r) * noise)
        domePos.setZ(i, z + (z / r) * noise)
      }
    }
    domeGeom.computeVertexNormals()
    const rockMat = new THREE.MeshStandardMaterial({
      color: 0x6a5848,
      roughness: 1,
      flatShading: true,
    })
    this.volcanoRock = new THREE.Mesh(domeGeom, rockMat)
    this.volcanoRock.position.set(VOLCANO_X, domeBaseY, VOLCANO_Z)
    this.volcanoRock.castShadow = true
    this.volcanoRock.receiveShadow = true
    this.scene.add(this.volcanoRock)

    // === Schnee-Kappe = Klon der Kuppel, leicht aufgeblasen, weiß ===
    // Wir klonen die Geometrie und verschieben jeden Vertex 0.03 Units entlang
    // der Normalen nach außen — so liegt der Schnee knapp oberhalb der Fels-Oberfläche.
    const snowGeom = domeGeom.clone()
    const snowPos = snowGeom.attributes.position as THREE.BufferAttribute
    const snowNormals = snowGeom.attributes.normal as THREE.BufferAttribute
    for (let i = 0; i < snowPos.count; i++) {
      const nx = snowNormals.getX(i)
      const ny = snowNormals.getY(i)
      const nz = snowNormals.getZ(i)
      snowPos.setX(i, snowPos.getX(i) + nx * SNOW_OFFSET)
      snowPos.setY(i, snowPos.getY(i) + ny * SNOW_OFFSET)
      snowPos.setZ(i, snowPos.getZ(i) + nz * SNOW_OFFSET)
    }
    snowPos.needsUpdate = true
    snowGeom.computeVertexNormals()

    // === Clipping-Plane: schneidet den Schnee VON UNTEN ab ===
    // Normal zeigt nach OBEN (+Y), Konstante wandert mit der Schmelze hoch.
    // Alles unterhalb der Ebene wird weggeclipped.
    // Die Konstante ist negativ: mehr negativ = Ebene weiter unten → mehr Schnee sichtbar.
    // Wir starten bei "alles sichtbar" → Konstante = -(-∞), wir nehmen sehr großen Wert.
    this.snowClipPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 1000)
    // Renderer muss lokales Clipping aktivieren
    this.renderer.localClippingEnabled = true

    const snowMat = new THREE.MeshStandardMaterial({
      color: 0xfafcfd,
      roughness: 0.6,
      flatShading: true,
      clippingPlanes: [this.snowClipPlane],
      clipShadows: true,
    })
    this.glacierIce = new THREE.Mesh(snowGeom, snowMat)
    this.glacierIce.position.set(VOLCANO_X, domeBaseY, VOLCANO_Z)
    this.glacierIce.castShadow = true
    this.glacierIce.receiveShadow = true
    this.scene.add(this.glacierIce)

    // Unused fallback (alter Code referenziert glacierSnow noch irgendwo)
    this.glacierSnow = new THREE.Mesh(
      new THREE.BufferGeometry(),
      new THREE.MeshBasicMaterial({ visible: false }),
    )
    this.scene.add(this.glacierSnow)
    // Merke die absolute Y-Basis des Schnee-Meshes (für die Clip-Plane Konstante)
    this.snowBaseWorldY = domeBaseY
  }

  // ============================================================
  // FX-System: schwebende Effekte (Totenköpfe, Flucht-Boote, ...)
  // ============================================================

  /**
   * Erzeugt ein Plane-Sprite mit einem Emoji als Textur.
   * Das Sprite schaut immer zur Kamera (wird im Update neu ausgerichtet).
   */
  private makeEmojiSprite(emoji: string, size = 0.6): THREE.Mesh {
    const canvas = document.createElement('canvas')
    canvas.width = 128
    canvas.height = 128
    const ctx = canvas.getContext('2d')!
    ctx.font = '96px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(emoji, 64, 72)
    const tex = new THREE.CanvasTexture(canvas)
    tex.minFilter = THREE.LinearFilter
    tex.magFilter = THREE.LinearFilter
    const mat = new THREE.MeshBasicMaterial({
      map: tex,
      transparent: true,
      depthWrite: false,
      side: THREE.DoubleSide,
    })
    const geom = new THREE.PlaneGeometry(size, size)
    const mesh = new THREE.Mesh(geom, mat)
    mesh.userData.isBillboard = true
    return mesh
  }

  /** Spawnt einen Totenkopf der über einer Position nach oben schwebt und verblasst. */
  private spawnSkull(x: number, y: number, z: number): void {
    const sprite = this.makeEmojiSprite('💀', 0.55)
    sprite.position.set(x, y + 0.4, z)
    this.fxGroup.add(sprite)
    const startY = sprite.position.y
    this.fxEffects.push({
      root: sprite,
      lifetime: 3.2,
      age: 0,
      update: (dt, ef) => {
        ef.age += dt
        const t = ef.age / ef.lifetime
        sprite.position.y = startY + t * 1.2
        // leichtes Wackeln
        sprite.position.x = x + Math.sin(ef.age * 4) * 0.05
        const opacity = Math.max(0, 1 - t)
        ;(sprite.material as THREE.MeshBasicMaterial).opacity = opacity
        if (t >= 1) {
          this.fxGroup.remove(sprite)
          ;(sprite.material as THREE.MeshBasicMaterial).dispose()
          sprite.geometry.dispose()
          return false
        }
        return true
      },
    })
  }

  /**
   * Spawnt ein Flucht-Boot: legt direkt am Steg an, eine wartende Figur
   * "schiebt sich" auf das Boot (kurze Boarding-Animation, ~2 s), dann
   * fährt das Boot in halbem Tempo eine Spirale um die Insel und verschwindet
   * über den Horizont.
   *
   * Visualisierung:
   *  - Bootsrumpf groß, Mast + Segel
   *  - 3 Personen an Bord als Paper-Mario-Sprites (Emoji-Billboards)
   *  - Eine zusätzliche Boarding-Figur, die vom Steg-Ende auf das Bootsdeck
   *    interpoliert (lineare Position-Lerp, KEINE Geh-Animation)
   */
  private spawnRefugeeBoat(): void {
    const group = new THREE.Group()
    // Rumpf
    const hull = new THREE.Mesh(
      new THREE.BoxGeometry(1.8, 0.40, 0.85),
      new THREE.MeshStandardMaterial({ color: 0x9a6a3a, roughness: 1 }),
    )
    hull.position.y = 0.20
    group.add(hull)
    // Bugspitze (kleines Trapez vorne)
    const bow = new THREE.Mesh(
      new THREE.BoxGeometry(0.45, 0.32, 0.6),
      new THREE.MeshStandardMaterial({ color: 0x8a5a2a, roughness: 1 }),
    )
    bow.position.set(0.95, 0.22, 0)
    group.add(bow)
    // Mast
    const mast = new THREE.Mesh(
      new THREE.CylinderGeometry(0.045, 0.045, 1.15, 6),
      new THREE.MeshStandardMaterial({ color: 0x6a4a2a, roughness: 1 }),
    )
    mast.position.set(0, 0.85, 0)
    group.add(mast)
    // Großes Segel
    const sail = new THREE.Mesh(
      new THREE.BoxGeometry(0.85, 0.95, 0.012),
      new THREE.MeshStandardMaterial({ color: 0xf0e8d4, roughness: 1, side: THREE.DoubleSide }),
    )
    sail.position.set(0.18, 0.85, 0)
    sail.rotation.y = Math.PI / 2  // Segel quer zum Boot
    group.add(sail)

    // 3 Passagiere als ganze stehende Menschen (Single-Codepoint-Emojis)
    // Sie bekommen userData.isBillboard, damit der Update-Loop sie zur Kamera dreht.
    const passengerEmojis = ['🧍', '🚶', '🧍']
    const passengers: THREE.Mesh[] = []
    for (let i = 0; i < 3; i++) {
      const sprite = this.makeEmojiSprite(passengerEmojis[i], 0.48)
      // Höher gesetzt, damit Füße auf dem Bootsdeck stehen statt im Rumpf
      sprite.position.set(-0.45 + i * 0.42, 0.65, 0.0)
      group.add(sprite)
      passengers.push(sprite)
    }
    // Boarding-Figur — startet auf dem Steg-Plankenniveau (lokal)
    const boarder = this.makeEmojiSprite('🧍', 0.48)
    group.add(boarder)
    // Boot-relative Zielposition (auf dem Bootsdeck)
    const boarderTargetX = 0.55
    const boarderTargetY = 0.65
    const boarderTargetZ = 0.0

    // === Bewegungs-Choreografie: Boarding → Spirale → Escape ===
    // Phase 0 (BOARDING): 0..2 s — Boot liegt am Steg, Boarding-Figur gleitet
    // Phase 1 (SPIRAL):   2..18 s — eine volle, langsame Umrundung
    // Phase 2 (ESCAPE):  18..28 s — tangentiale Flucht über den Horizont
    const BOARDING_DUR = 2.0
    const SPIRAL_DUR = 16.0
    const ESCAPE_DUR = 10.0
    const TOTAL_LIFETIME = BOARDING_DUR + SPIRAL_DUR + ESCAPE_DUR

    // Steg-Position als Startpunkt
    const startX = this.pierEndPos.x
    const startZ = this.pierEndPos.z
    const baseY = WATER_Y_BASE + 0.12
    group.position.set(startX, baseY, startZ)
    // Boot zeigt initial nach vorne (+Z, Richtung offenes Meer)
    group.rotation.y = -Math.PI / 2

    // Boarding-Figur startet AUSSERHALB des Boots, in lokalen Koordinaten
    // links vom Bootsdeck — auf dem Steg-Plankenniveau.
    // Plankenoberkante = WORLD_Y 0.85, Bootsorigin = baseY (~0.12),
    // also lokal ≈ 0.85 - 0.12 + halbe Sprite-Höhe ≈ 0.97. Wir nehmen 1.0.
    // (Lokale Koords, weil Boarder Kind von group ist; group steht aber noch still.)
    const boarderStartX = -1.10
    const boarderStartY = 1.00
    const boarderStartZ = 0.0
    boarder.position.set(boarderStartX, boarderStartY, boarderStartZ)

    // Spirale: Insel-Mitte (0,-2), Radius wächst, Uhrzeigersinn
    const centerX = 0
    const centerZ = -2
    const r0 = 22
    const r1 = 28
    const spiralStartAngle = Math.atan2(startZ - centerZ, startX - centerX)

    let escapeStartX = 0
    let escapeStartZ = 0
    let escapeDirX = 0
    let escapeDirZ = 0
    let escapeReady = false

    this.fxGroup.add(group)
    this.fxEffects.push({
      root: group,
      lifetime: TOTAL_LIFETIME,
      age: 0,
      update: (dt, ef) => {
        ef.age += dt
        const age = ef.age

        const prevX = group.position.x
        const prevZ = group.position.z

        if (age < BOARDING_DUR) {
          // === BOARDING ===
          // Boot bleibt am Steg, Figur gleitet linear in Bootskoordinaten.
          const u = age / BOARDING_DUR
          boarder.position.x = boarderStartX + (boarderTargetX - boarderStartX) * u
          boarder.position.y = boarderStartY + (boarderTargetY - boarderStartY) * u
          boarder.position.z = boarderStartZ + (boarderTargetZ - boarderStartZ) * u
          // Sanftes Wiegen am Steg
          group.rotation.z = Math.sin(age * 2) * 0.03
        } else if (age < BOARDING_DUR + SPIRAL_DUR) {
          // === SPIRALE (langsam, eine volle Umrundung) ===
          const u = (age - BOARDING_DUR) / SPIRAL_DUR
          const angle = spiralStartAngle - u * Math.PI * 2
          const r = r0 + (r1 - r0) * u
          group.position.x = centerX + Math.cos(angle) * r
          group.position.z = centerZ + Math.sin(angle) * r
          // Wenn die Spirale fast zu Ende → Escape-Tangente einfrieren
          if (u > 0.985 && !escapeReady) {
            escapeStartX = group.position.x
            escapeStartZ = group.position.z
            const vx = group.position.x - prevX
            const vz = group.position.z - prevZ
            const vlen = Math.sqrt(vx * vx + vz * vz) || 1
            escapeDirX = vx / vlen
            escapeDirZ = vz / vlen
            escapeReady = true
          }
        } else {
          // === ESCAPE — über den Horizont ===
          if (!escapeReady) {
            escapeStartX = group.position.x
            escapeStartZ = group.position.z
            escapeDirX = 1
            escapeDirZ = 1
            escapeReady = true
          }
          const u = (age - BOARDING_DUR - SPIRAL_DUR) / ESCAPE_DUR
          // Beschleunigt — gleiche Distanz wie vorher (~110 Units),
          // aber über doppelt so viel Zeit (10 s statt ~5 s)
          const dist = Math.pow(u, 1.4) * 110
          group.position.x = escapeStartX + escapeDirX * dist
          group.position.z = escapeStartZ + escapeDirZ * dist
        }

        // Ausrichtung: Boot zeigt in Bewegungsrichtung (außer beim Boarding)
        const dx = group.position.x - prevX
        const dz = group.position.z - prevZ
        if (age >= BOARDING_DUR && dx * dx + dz * dz > 1e-6) {
          group.rotation.y = Math.atan2(-dz, dx)
        }

        // Sanftes Wiegen während der Fahrt
        if (age >= BOARDING_DUR) {
          group.rotation.z = Math.sin(age * 2) * 0.04
          group.rotation.x = Math.sin(age * 1.4) * 0.025
        }

        // Letzte 15 % der Gesamtdauer ausblenden
        const t = age / TOTAL_LIFETIME
        if (t > 0.85) {
          const fade = 1 - (t - 0.85) / 0.15
          group.traverse((obj) => {
            const m = (obj as THREE.Mesh).material as THREE.MeshBasicMaterial | THREE.MeshStandardMaterial | undefined
            if (m && m.opacity !== undefined) {
              m.transparent = true
              m.opacity = Math.max(0, fade)
            }
          })
        }
        if (t >= 1) {
          this.fxGroup.remove(group)
          return false
        }
        return true
      },
    })
  }

  /** Liegt der Punkt im Schatten/Bereich des Vulkans? */
  private isInVolcanoArea(x: number, z: number, padding = 1.5): boolean {
    const dx = x - VOLCANO_X
    const dz = z - VOLCANO_Z
    // Wir nehmen den Kuppel-Radius plus etwas Puffer,
    // damit nichts direkt am Vulkan platziert wird
    return Math.sqrt(dx * dx + dz * dz) < VOLCANO_DOME_RADIUS + 0.8 + padding
  }

  private buildStartingVillage(): void {
    // Dorf liegt auf dem mittleren bis hinteren (höheren) Teil der Insel.
    // Tiefland vorne (+Z) bleibt leer — wird vom Wasser erobert.
    const houseCount = 18
    this.houseAnchors = []
    let placed = 0
    let attempts = 0
    while (placed < houseCount && attempts < 500) {
      attempts++
      const rx = this.seededRand(attempts * 13 + 7) * 2 - 1
      const rz = this.seededRand(attempts * 17 + 3) * 2 - 1
      // Dorf-Bereich: mittelbreit (± 7), zentral bis Mitte-hinten (-6..+2 in Z)
      const x = rx * 7
      const z = -2 + rz * 6
      if (this.isInVolcanoArea(x, z, 1.0)) continue
      const y = this.terrainHeight(x, z)
      // Nur auf mittlerer Höhe (Wiese), nicht am Vulkanhang und nicht am Strand
      if (y < 0.35 || y > 1.3) continue
      const house = this.makeStarterHouse(this.seededRand(placed * 23))
      house.position.set(x, y, z)
      house.rotation.y = this.seededRand(placed * 31) * Math.PI * 2
      this.villageGroup.add(house)
      this.houseAnchors.push(new THREE.Vector3(x, y + 0.85, z))
      placed++
    }
    // Bäume breit verteilt. Typ richtet sich nach Höhenzone:
    //  Strand (0.15..0.45)  → Palme (hitze-resistent)
    //  Wiese  (0.45..1.10)  → Laubbaum (mittel)
    //  Wald   (1.10..1.80)  → Nadelbaum (empfindlich)
    // Wir tracken die Bäume für die spätere Klima-Gesundheit.
    this.trees = []
    let treesPlaced = 0
    let treeAttempts = 0
    while (treesPlaced < 32 && treeAttempts < 500) {
      treeAttempts++
      const rx = this.seededRand(treeAttempts * 41 + 7) * 2 - 1
      const rz = this.seededRand(treeAttempts * 43 + 11) * 2 - 1
      const x = rx * ISLAND_HALF_WIDTH * 0.85
      const z = rz * ISLAND_HALF_LENGTH * 0.90
      if (this.isInVolcanoArea(x, z, -0.2)) continue
      const y = this.terrainHeight(x, z)
      let kind: 'palm' | 'leaf' | 'conifer'
      if (y >= 0.15 && y < 0.45) kind = 'palm'
      else if (y >= 0.45 && y < 1.10) kind = 'leaf'
      else if (y >= 1.10 && y < 1.80) kind = 'conifer'
      else continue
      const tree = this.makeBackgroundTree(kind)
      tree.position.set(x, y, z)
      // kleine Rotation-Varianz
      tree.rotation.y = this.seededRand(treeAttempts * 71 + 3) * Math.PI * 2
      this.villageGroup.add(tree)
      this.trees.push({ mesh: tree, kind, anchorY: y, stage: 'alive' })
      treesPlaced++
    }
  }

  /**
   * Pro Haus eine Menge kleiner Rauchwolken die aufsteigen und driften.
   * Jede Puff hat eigene Lebensdauer, Aufstiegsgeschwindigkeit und Wind-Drift.
   * Sichtbarkeit = wie viele Häuser noch keinen Solar haben.
   */
  private buildSmokePlumes(): void {
    this.smokePuffs = []
    for (let i = 0; i < this.houseAnchors.length; i++) {
      const puffs: SmokePuff[] = []
      // Pro Haus 3 Puffs, zeitlich versetzt
      const count = 3
      for (let j = 0; j < count; j++) {
        const baseRadius = 0.08 + Math.random() * 0.06
        const geom = new THREE.SphereGeometry(baseRadius, 10, 8)
        const mat = new THREE.MeshStandardMaterial({
          color: 0x9a9088,
          transparent: true,
          opacity: 0.55,
          roughness: 1,
          metalness: 0,
        })
        const mesh = new THREE.Mesh(geom, mat)
        this.smokeGroup.add(mesh)
        puffs.push({
          mesh,
          lifetime: 2.5 + Math.random() * 1.5,
          // Zeitlich versetzt starten — nicht alle gleichzeitig
          age: (j / count) * (2.5 + Math.random() * 1.5),
          speedY: 0.35 + Math.random() * 0.25,
          windAmp: 0.2 + Math.random() * 0.25,
          windPhase: Math.random() * Math.PI * 2,
          baseRadius,
        })
      }
      this.smokePuffs.push(puffs)
    }
  }

  private makeStarterHouse(variant: number): THREE.Object3D {
    const group = new THREE.Group()
    const w = 0.55 + variant * 0.15
    const d = 0.50 + variant * 0.15
    const h = 0.40 + variant * 0.12
    // Wände
    const wallMat = new THREE.MeshStandardMaterial({
      color: variant > 0.6 ? 0xe8d8be : 0xd6c8a8,
      roughness: 1,
    })
    const walls = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wallMat)
    walls.position.y = h / 2
    walls.castShadow = true
    walls.receiveShadow = true
    group.add(walls)
    // Spitzdach
    const roofMat = new THREE.MeshStandardMaterial({
      color: variant > 0.5 ? 0x9a4a3a : 0x7a3a2a,
      roughness: 1,
    })
    const roof = new THREE.Mesh(
      new THREE.ConeGeometry(Math.max(w, d) * 0.78, 0.32, 4),
      roofMat,
    )
    roof.rotation.y = Math.PI / 4
    roof.position.y = h + 0.16
    roof.castShadow = true
    group.add(roof)
    return group
  }

  /**
   * Erstellt einen Baum nach Typ mit Klima-Resistenz-Marker.
   * - 'palm':    sehr resistent, hält bis ~17.5°C
   * - 'leaf':    mittel resistent, hält bis ~16.8°C
   * - 'conifer': empfindlich, wird ab ~16.3°C braun
   */
  private makeBackgroundTree(kind: 'palm' | 'leaf' | 'conifer' = 'leaf'): THREE.Object3D {
    const group = new THREE.Group()
    const leafMat = new THREE.MeshStandardMaterial({
      color: kind === 'palm' ? 0x4ba055 : kind === 'conifer' ? 0x3a6a3a : 0x55994a,
      roughness: 1,
    })
    const trunkMat = new THREE.MeshStandardMaterial({
      color: kind === 'palm' ? 0x8a6a3a : 0x6a4a2a,
      roughness: 1,
    })

    if (kind === 'palm') {
      // Schlanker, hoher Stamm + 5 Blatt-Wedel
      const trunk = new THREE.Mesh(
        new THREE.CylinderGeometry(0.05, 0.08, 0.95, 6),
        trunkMat,
      )
      trunk.position.y = 0.475
      trunk.castShadow = true
      group.add(trunk)
      // Blattwedel: flache Boxen, sternförmig nach außen geneigt
      for (let i = 0; i < 5; i++) {
        const blade = new THREE.Mesh(
          new THREE.BoxGeometry(0.55, 0.04, 0.18),
          leafMat,
        )
        blade.geometry.translate(0.27, 0, 0)  // Mittelpunkt am Stamm
        const angle = (i / 5) * Math.PI * 2
        blade.rotation.y = angle
        blade.rotation.z = -0.35  // leicht hängend
        blade.position.y = 0.95
        blade.castShadow = true
        group.add(blade)
      }
    } else if (kind === 'conifer') {
      // Schlanker Stamm + spitzer Kegel
      const trunk = new THREE.Mesh(
        new THREE.CylinderGeometry(0.05, 0.08, 0.32, 6),
        trunkMat,
      )
      trunk.position.y = 0.16
      trunk.castShadow = true
      group.add(trunk)
      const cone = new THREE.Mesh(
        new THREE.ConeGeometry(0.32, 0.95, 8),
        leafMat,
      )
      cone.position.y = 0.78
      cone.castShadow = true
      group.add(cone)
    } else {
      // Laubbaum: kurzer Stamm + bauschige Krone
      const trunk = new THREE.Mesh(
        new THREE.CylinderGeometry(0.07, 0.11, 0.42, 6),
        trunkMat,
      )
      trunk.position.y = 0.21
      trunk.castShadow = true
      group.add(trunk)
      const crown = new THREE.Mesh(
        new THREE.SphereGeometry(0.42, 10, 8),
        leafMat,
      )
      crown.position.y = 0.72
      crown.castShadow = true
      crown.scale.set(1, 0.85, 1)
      group.add(crown)
    }

    // Marker für das Update-System
    group.userData.treeKind = kind
    group.userData.leafMat = leafMat
    group.userData.trunkMat = trunkMat
    return group
  }

  // ============================================================
  // Resize
  // ============================================================

  private measure(): { width: number; height: number } {
    const rect = this.container.getBoundingClientRect()
    return {
      width: rect.width || window.innerWidth,
      height: rect.height || window.innerHeight,
    }
  }

  private onResize(): void {
    const { width, height } = this.measure()
    this.renderer.setSize(width, height)
    this.camera.aspect = width / height
    this.camera.updateProjectionMatrix()
  }

  // ============================================================
  // Loop
  // ============================================================

  start(): void {
    let lastFrame = performance.now()
    const loop = (now: number) => {
      const dt = (now - lastFrame) / 1000
      lastFrame = now
      this.t += dt
      this.updateScene(dt)
      this.draw()
      this.animId = requestAnimationFrame(loop)
    }
    this.animId = requestAnimationFrame(loop)
  }

  stop(): void {
    cancelAnimationFrame(this.animId)
    window.removeEventListener('resize', this.resizeHandler)
    const dom = this.renderer.domElement
    dom.removeEventListener('pointerdown', this.pointerDownHandler)
    window.removeEventListener('pointermove', this.pointerMoveHandler)
    window.removeEventListener('pointerup', this.pointerUpHandler)
    dom.removeEventListener('wheel', this.wheelHandler)
  }

  private seededRand(seed: number): number {
    const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
    return x - Math.floor(x)
  }

  /**
   * Glatte Höhenfunktion des Insel-Terrains. Besteht aus drei Komponenten:
   * 1) Ellipsen-Falloff (Insel taucht sanft ins Meer)
   * 2) Keil-Kippung (hinten hoch, vorne flach)
   * 3) Gauss-Erhebung für den Vulkan
   * Plus leichtes Noise für natürliche Hügel.
   */
  private terrainHeight(x: number, z: number): number {
    // Ellipsen-Distanz 0 = Mitte, 1 = Rand
    const ex = x / ISLAND_HALF_WIDTH
    const ez = z / ISLAND_HALF_LENGTH
    const edge = Math.sqrt(ex * ex + ez * ez)
    // Smoothstep-Falloff: bei 0..0.75 voll, bei 0.75..1.08 glatt runter auf 0
    const falloff = 1 - smoothstep(0.78, 1.08, edge)

    // Keil: hinten (−Z) hoch, vorne (+Z) niedrig
    const wedgeT = (z + ISLAND_HALF_LENGTH) / (ISLAND_HALF_LENGTH * 2)
    const wedge = ISLAND_HIGH_EDGE_Y * (1 - wedgeT) + ISLAND_LOW_EDGE_Y * wedgeT

    // Vulkan als Gauss-Erhebung
    const dvx = x - VOLCANO_X
    const dvz = z - VOLCANO_Z
    const vDist2 = dvx * dvx + dvz * dvz
    const volcanoBump = VOLCANO_BUMP_HEIGHT *
      Math.exp(-vDist2 / (VOLCANO_BASE_RADIUS * VOLCANO_BASE_RADIUS))

    // Sanfte Hügel
    const noise =
      Math.sin(x * 0.35) * 0.14 +
      Math.cos(z * 0.28) * 0.11 +
      Math.sin(x * 0.22 + z * 0.18) * 0.09

    // Kombinieren: alles × Falloff, außerhalb deutlich unter Wasser
    const land = (wedge + volcanoBump + noise) * falloff
    return land - (1 - falloff) * 2.5
  }

  /** Höhe der Insel an (x,z) — Alias für API-Kompatibilität */
  private islandHeightAt(x: number, z: number): number {
    return this.terrainHeight(x, z)
  }

  /** Ist der Punkt auf der (trockenen) Insel? */
  private isOnIsland(x: number, z: number, minHeight = 0.1): boolean {
    return this.terrainHeight(x, z) >= minHeight
  }

  /**
   * Farbe eines Vertex basierend auf seiner Höhe.
   * Strand → Wiese → Wald → Fels (plus Unterwasser-Dunkel).
   */
  private zoneColor(y: number): [number, number, number] {
    if (y < 0) {
      // unter Wasser: dunkelblau-grau
      return [0.25, 0.35, 0.38]
    }
    if (y < ZONE_BEACH_END) {
      // Strand: heller Sand
      return [0.94, 0.87, 0.67]
    }
    if (y < ZONE_MEADOW_END) {
      // Helle Wiese
      const t = (y - ZONE_BEACH_END) / (ZONE_MEADOW_END - ZONE_BEACH_END)
      // Sand-zu-Gras-Gradient für weichen Übergang
      const r = lerp(0.82, 0.52, t)
      const g = lerp(0.82, 0.72, t)
      const b = lerp(0.55, 0.38, t)
      return [r, g, b]
    }
    if (y < ZONE_FOREST_END) {
      // Dunkler Wald
      const t = (y - ZONE_MEADOW_END) / (ZONE_FOREST_END - ZONE_MEADOW_END)
      const r = lerp(0.52, 0.38, t)
      const g = lerp(0.72, 0.56, t)
      const b = lerp(0.38, 0.30, t)
      return [r, g, b]
    }
    // Fels (Vulkan-Hang)
    const t = Math.min(1, (y - ZONE_FOREST_END) / 1.5)
    const r = lerp(0.42, 0.48, t)
    const g = lerp(0.38, 0.35, t)
    const b = lerp(0.32, 0.30, t)
    return [r, g, b]
  }

  // ============================================================
  // Maßnahmen platzieren
  // ============================================================

  /**
   * Erzeugt ein Mesh für die gegebene Maßnahme. Wenn `position` übergeben wird
   * (Baueditor-Modus), wird das Mesh dort platziert. Sonst wird die alte
   * Auto-Platzierung verwendet (zufällig + heuristisch).
   */
  private placeMeasure(typeId: string, index: number, position?: { x: number; z: number }): THREE.Object3D | null {
    // Deiche, Mauern, Mangroven, Sand: an die Küstenlinie. Rest aufs Land.
    const isShoreObject = typeId === 'dike' || typeId === 'sea-wall'
                       || typeId === 'mangrove' || typeId === 'sand-fill'
    const baseSeed = typeId.charCodeAt(0) * 97 + (typeId.charCodeAt(1) || 0) * 13 + index * 41

    let x = 0, z = 0, angle = 0

    if (position) {
      // === Baueditor-Modus: feste Position vom Spieler ===
      x = position.x
      z = position.z
      // Winkel: bei Ufer-Objekten zur Küstennormalen, sonst Atan2
      angle = Math.atan2(z, x)
    } else {
      // === Auto-Platzierung (Legacy / Saves vor Baueditor) ===
      let found = false
      if (isShoreObject) {
        for (let attempt = 0; attempt < 60; attempt++) {
          angle = this.seededRand(baseSeed + attempt * 7) * Math.PI * 2
          if (Math.sin(angle) < -0.3) continue
          const rFactor = 0.82 + this.seededRand(baseSeed + attempt * 11) * 0.10
          x = Math.cos(angle) * ISLAND_HALF_WIDTH * rFactor
          z = Math.sin(angle) * ISLAND_HALF_LENGTH * rFactor
          const h = this.terrainHeight(x, z)
          if (h > 0.05 && h < 0.40 && !this.isInVolcanoArea(x, z, -0.3)) {
            found = true
            break
          }
        }
        if (!found) {
          angle = Math.PI / 2
          x = 0
          z = ISLAND_HALF_LENGTH * 0.78
        }
      } else {
        for (let attempt = 0; attempt < 60; attempt++) {
          const rx = this.seededRand(baseSeed + attempt * 7) * 2 - 1
          const rz = this.seededRand(baseSeed + attempt * 11) * 2 - 1
          x = rx * ISLAND_HALF_WIDTH * 0.7
          z = rz * ISLAND_HALF_LENGTH * 0.65
          if (this.isInVolcanoArea(x, z, 0.8)) continue
          const h = this.terrainHeight(x, z)
          if (h < 0.45 || h > 1.7) continue
          found = true
          angle = Math.atan2(z, x)
          break
        }
        if (!found) return null
      }
    }

    let obj: THREE.Object3D | null = null
    switch (typeId) {
      case 'forest':      obj = this.makeTree(); break
      case 'solar':       obj = this.makeSolarPanel(); break
      case 'wind':        obj = this.makeWindTurbine(); break
      case 'green-roof':  obj = this.makeGreenRoof(); break
      case 'dike':        obj = this.makeDike(angle); break
      case 'sea-wall':    obj = this.makeSeaWall(angle); break
      case 'coal':        obj = this.makeCoalPlant(); break
      case 'airport':     obj = this.makeAirport(); break
      case 'cloud-seed':  obj = this.makeCloudSeeder(); break
      case 'bikes':       obj = this.makeBikePath(); break
      case 'mangrove':    obj = this.makeMangrove(angle); break
      case 'sand-fill':   obj = this.makeSandFill(angle); break
    }
    if (!obj) return null

    const y = this.islandHeightAt(x, z)
    obj.position.set(x, Math.max(0, y), z)
    this.scene.add(obj)
    return obj
  }

  private makeTree(): THREE.Object3D {
    const group = new THREE.Group()
    const trunk = new THREE.Mesh(
      new THREE.CylinderGeometry(0.08, 0.12, 0.5, 6),
      new THREE.MeshStandardMaterial({ color: 0x5a3a22, roughness: 1 }),
    )
    trunk.position.y = 0.25
    trunk.castShadow = true
    group.add(trunk)
    const leaves = new THREE.Mesh(
      new THREE.ConeGeometry(0.35, 0.85, 8),
      new THREE.MeshStandardMaterial({ color: 0x3a7a4a, roughness: 1 }),
    )
    leaves.position.y = 0.9
    leaves.castShadow = true
    group.add(leaves)
    const leaves2 = new THREE.Mesh(
      new THREE.ConeGeometry(0.28, 0.6, 8),
      new THREE.MeshStandardMaterial({ color: 0x4a8a5a, roughness: 1 }),
    )
    leaves2.position.y = 1.2
    leaves2.castShadow = true
    group.add(leaves2)
    return group
  }

  private makeSolarPanel(): THREE.Object3D {
    const group = new THREE.Group()
    const stand = new THREE.Mesh(
      new THREE.BoxGeometry(0.04, 0.35, 0.04),
      new THREE.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 1 }),
    )
    stand.position.y = 0.18
    group.add(stand)
    const panel = new THREE.Mesh(
      new THREE.BoxGeometry(0.8, 0.04, 0.5),
      new THREE.MeshStandardMaterial({ color: 0x1e3a5a, roughness: 0.4, metalness: 0.3 }),
    )
    panel.position.y = 0.38
    panel.rotation.x = -Math.PI / 8
    panel.castShadow = true
    group.add(panel)
    return group
  }

  private makeWindTurbine(): THREE.Object3D {
    const group = new THREE.Group()
    const mast = new THREE.Mesh(
      new THREE.CylinderGeometry(0.05, 0.08, 1.8, 8),
      new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 0.6 }),
    )
    mast.position.y = 0.9
    mast.castShadow = true
    group.add(mast)
    const hub = new THREE.Mesh(
      new THREE.SphereGeometry(0.08, 12, 8),
      new THREE.MeshStandardMaterial({ color: 0xdddddd }),
    )
    hub.position.y = 1.8
    group.add(hub)
    const rotor = new THREE.Group()
    rotor.position.y = 1.8
    for (let i = 0; i < 3; i++) {
      const blade = new THREE.Mesh(
        new THREE.BoxGeometry(0.05, 0.7, 0.03),
        new THREE.MeshStandardMaterial({ color: 0xfafafa, roughness: 0.8 }),
      )
      blade.geometry.translate(0, 0.35, 0)
      blade.rotation.z = (i * Math.PI * 2) / 3
      rotor.add(blade)
    }
    group.add(rotor)
    group.userData.rotorRef = rotor
    return group
  }

  private makeGreenRoof(): THREE.Object3D {
    const group = new THREE.Group()
    const house = new THREE.Mesh(
      new THREE.BoxGeometry(0.7, 0.5, 0.7),
      new THREE.MeshStandardMaterial({ color: 0xd8cfbe, roughness: 1 }),
    )
    house.position.y = 0.25
    house.castShadow = true
    house.receiveShadow = true
    group.add(house)
    const roof = new THREE.Mesh(
      new THREE.BoxGeometry(0.8, 0.08, 0.8),
      new THREE.MeshStandardMaterial({ color: 0x5a8a5a, roughness: 1 }),
    )
    roof.position.y = 0.54
    roof.castShadow = true
    group.add(roof)
    return group
  }

  private makeDike(angle: number): THREE.Object3D {
    const group = new THREE.Group()
    // Flacher Erddeich — deutlich kleiner (ca. 1/4 der alten Hoehe)
    const dikeBody = new THREE.Mesh(
      new THREE.BoxGeometry(2.0, 0.22, 0.6),
      new THREE.MeshStandardMaterial({ color: 0x8a6f4a, roughness: 1 }),
    )
    dikeBody.position.y = 0.11
    dikeBody.castShadow = true
    dikeBody.receiveShadow = true
    group.add(dikeBody)
    // Grasige Deichkrone
    const crown = new THREE.Mesh(
      new THREE.BoxGeometry(2.0, 0.04, 0.35),
      new THREE.MeshStandardMaterial({ color: 0x5a8a4a, roughness: 1 }),
    )
    crown.position.y = 0.24
    crown.castShadow = true
    group.add(crown)
    // Damit die Längsseite parallel zur Küste verläuft
    group.rotation.y = -angle + Math.PI / 2
    return group
  }

  private makeSeaWall(angle: number): THREE.Object3D {
    const group = new THREE.Group()
    // Hohe Betonmauer
    const wall = new THREE.Mesh(
      new THREE.BoxGeometry(2.6, 1.25, 0.30),
      new THREE.MeshStandardMaterial({ color: 0xb0b3b5, roughness: 0.95 }),
    )
    wall.position.y = 0.62
    wall.castShadow = true
    wall.receiveShadow = true
    group.add(wall)
    // Krone (dunklerer Streifen oben)
    const cap = new THREE.Mesh(
      new THREE.BoxGeometry(2.62, 0.08, 0.34),
      new THREE.MeshStandardMaterial({ color: 0x70757a, roughness: 1 }),
    )
    cap.position.y = 1.28
    group.add(cap)
    group.rotation.y = -angle + Math.PI / 2
    return group
  }

  /** Kohlekraftwerk: dunkler Block mit zwei Schornsteinen, die qualmen.
   *  Die Schornsteine bekommen eine eigene Smoke-Marker, damit das System
   *  später noch dichten Rauch ausstoßen kann. */
  private makeCoalPlant(): THREE.Object3D {
    const group = new THREE.Group()
    const body = new THREE.Mesh(
      new THREE.BoxGeometry(0.95, 0.55, 0.85),
      new THREE.MeshStandardMaterial({ color: 0x4a4a52, roughness: 1 }),
    )
    body.position.y = 0.28
    body.castShadow = true
    body.receiveShadow = true
    group.add(body)
    // Zwei Schornsteine
    for (const sx of [-0.22, 0.22]) {
      const stack = new THREE.Mesh(
        new THREE.CylinderGeometry(0.09, 0.10, 0.95, 8),
        new THREE.MeshStandardMaterial({ color: 0x5a5660, roughness: 1 }),
      )
      stack.position.set(sx, 0.78, -0.18)
      stack.castShadow = true
      group.add(stack)
      // Roter Streifen oben
      const ring = new THREE.Mesh(
        new THREE.CylinderGeometry(0.105, 0.105, 0.10, 8),
        new THREE.MeshStandardMaterial({ color: 0xa04a3a, roughness: 1 }),
      )
      ring.position.set(sx, 1.20, -0.18)
      group.add(ring)
    }
    return group
  }

  /** Tourismus-Flughafen: flaches Terminal mit Tower und einer kleinen Landebahn. */
  private makeAirport(): THREE.Object3D {
    const group = new THREE.Group()
    // Landebahn
    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
    runway.receiveShadow = true
    group.add(runway)
    // Markierungsstreifen (heller)
    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)
      group.add(stripe)
    }
    // Terminal
    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)
    terminal.castShadow = true
    group.add(terminal)
    // Tower
    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)
    tower.castShadow = true
    group.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)
    group.add(towerTop)
    return group
  }

  /** Wolken-Impfung: Antennen-Mast mit Schüssel — sieht nach Hightech aus. */
  private makeCloudSeeder(): THREE.Object3D {
    const group = new THREE.Group()
    const base = new THREE.Mesh(
      new THREE.BoxGeometry(0.4, 0.18, 0.4),
      new THREE.MeshStandardMaterial({ color: 0x9098a0, roughness: 1 }),
    )
    base.position.y = 0.09
    group.add(base)
    const mast = new THREE.Mesh(
      new THREE.CylinderGeometry(0.04, 0.05, 0.95, 8),
      new THREE.MeshStandardMaterial({ color: 0xb0b8c0, roughness: 0.6 }),
    )
    mast.position.y = 0.65
    mast.castShadow = true
    group.add(mast)
    // Parabolschüssel oben (halbe Sphäre)
    const dish = new THREE.Mesh(
      new THREE.SphereGeometry(0.28, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2),
      new THREE.MeshStandardMaterial({
        color: 0xe8eef2,
        roughness: 0.4,
        metalness: 0.5,
        side: THREE.DoubleSide,
      }),
    )
    dish.position.y = 1.10
    dish.rotation.x = -Math.PI / 4
    group.add(dish)
    return group
  }

  /** Radwege-Netz: kleines flaches Asphalt-Stück mit weißen Streifen + Fahrrad-Symbol-Streifen. */
  private makeBikePath(): THREE.Object3D {
    const group = new THREE.Group()
    // Asphaltband
    const asphalt = new THREE.Mesh(
      new THREE.BoxGeometry(1.4, 0.04, 0.45),
      new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 1 }),
    )
    asphalt.position.y = 0.02
    asphalt.receiveShadow = true
    group.add(asphalt)
    // Mittelstreifen (weiße Striche)
    for (let i = -2; i <= 2; i++) {
      const stripe = new THREE.Mesh(
        new THREE.BoxGeometry(0.18, 0.005, 0.04),
        new THREE.MeshStandardMaterial({ color: 0xf2f2f2, roughness: 1 }),
      )
      stripe.position.set(i * 0.28, 0.045, 0)
      group.add(stripe)
    }
    // Zwei kleine Rad-Stelen am Rand (deko)
    for (const sx of [-0.55, 0.55]) {
      const post = new THREE.Mesh(
        new THREE.CylinderGeometry(0.02, 0.025, 0.22, 5),
        new THREE.MeshStandardMaterial({ color: 0x6a8a4a, roughness: 1 }),
      )
      post.position.set(sx, 0.13, 0.30)
      group.add(post)
      const sign = new THREE.Mesh(
        new THREE.BoxGeometry(0.13, 0.13, 0.02),
        new THREE.MeshStandardMaterial({ color: 0x4a8a6a, roughness: 1 }),
      )
      sign.position.set(sx, 0.30, 0.30)
      group.add(sign)
    }
    return group
  }

  /** Mangrovenwald: dichter Cluster aus 3 kleinen, gedrungenen Bäumchen
   *  auf hohen Wurzelstelzen — am Ufer (kann ins Wasser ragen). */
  private makeMangrove(_angle: number): THREE.Object3D {
    const group = new THREE.Group()
    const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a3018, roughness: 1 })
    const leafMat  = new THREE.MeshStandardMaterial({ color: 0x2a6a3a, roughness: 1 })
    // 3 Mini-Mangroven
    const positions: Array<[number, number]> = [[-0.25, -0.18], [0.22, 0.0], [-0.05, 0.25]]
    for (const [px, pz] of positions) {
      // Wurzelstelzen — 3 dünne schräge Cylinder
      for (let i = 0; i < 3; i++) {
        const a = (i / 3) * Math.PI * 2
        const root = new THREE.Mesh(
          new THREE.CylinderGeometry(0.018, 0.025, 0.32, 5),
          trunkMat,
        )
        root.position.set(px + Math.cos(a) * 0.06, 0.16, pz + Math.sin(a) * 0.06)
        root.rotation.z = Math.cos(a) * 0.4
        root.rotation.x = Math.sin(a) * 0.4
        group.add(root)
      }
      // Stamm
      const trunk = new THREE.Mesh(
        new THREE.CylinderGeometry(0.05, 0.06, 0.30, 6),
        trunkMat,
      )
      trunk.position.set(px, 0.46, pz)
      trunk.castShadow = true
      group.add(trunk)
      // Krone — gedrungen, breit
      const leaves = new THREE.Mesh(
        new THREE.SphereGeometry(0.25, 10, 8),
        leafMat,
      )
      leaves.position.set(px, 0.68, pz)
      leaves.scale.set(1, 0.7, 1)
      leaves.castShadow = true
      group.add(leaves)
    }
    return group
  }

  /**
   * Sand-Aufschüttung: glockenförmiger Sand-Hügel (LatheGeometry) — wie eine
   * Mini-Variante der Vulkan-Kuppel. Wenn die Maßnahme erodiert, lässt der
   * Renderer den Hügel in updateScene() langsam einsinken (siehe `userData.maxY`).
   * Die Geometrie selbst hat einen Anchor unten, sodass `position.y` direkt
   * die Höhe der Spitze über dem Boden bestimmt.
   */
  private makeSandFill(_angle: number): THREE.Object3D {
    const group = new THREE.Group()
    const points: THREE.Vector2[] = []
    const segments = 12
    const radius = 0.85
    const height = 0.42
    for (let i = 0; i <= segments; i++) {
      const t = i / segments
      // Glockenprofil: r = R * cos(t * π/2)
      const r = radius * Math.cos(t * Math.PI / 2)
      const y = t * height
      points.push(new THREE.Vector2(r, y))
    }
    const geom = new THREE.LatheGeometry(points, 18)
    // Leichte Welligkeit für Naturlook
    const pos = geom.attributes.position as THREE.BufferAttribute
    for (let i = 0; i < pos.count; i++) {
      const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i)
      const noise = Math.sin(x * 5) * 0.015 + Math.cos(z * 4) * 0.012
      pos.setY(i, y + noise)
    }
    geom.computeVertexNormals()
    const sand = new THREE.Mesh(
      geom,
      new THREE.MeshStandardMaterial({ color: 0xe6cf8a, roughness: 1 }),
    )
    sand.castShadow = true
    sand.receiveShadow = true
    group.add(sand)
    // Merken: Maximalhöhe für die Erosions-Animation (siehe updateScene)
    group.userData.sandMaxHeight = height
    return group
  }

  /**
   * Holzsteg am vorderen Strand. Statisches Mesh — wird einmal gebaut.
   * Drei Holzpfähle aus dem Wasser, ein Plankenweg darüber, am Ende
   * stehen 3 wartende Bewohner-Sprites (Paper-Mario-Stil, immer sichtbar).
   */
  private buildPier(): void {
    const group = new THREE.Group()
    const woodMat = new THREE.MeshStandardMaterial({ color: 0x8a5a2a, roughness: 1 })
    const woodMatDark = new THREE.MeshStandardMaterial({ color: 0x6a4520, roughness: 1 })

    // Steg-Position: vorderes Drittel am Strand, leicht zur Seite
    const pierStartZ = ISLAND_HALF_LENGTH * 0.78  // am Ufer
    const pierEndZ = ISLAND_HALF_LENGTH * 0.78 + 3.6  // ragt 3.6 Units ins Wasser
    const pierX = 1.2

    // Plankenweg — DEUTLICH HÖHER, damit der Steg nicht früh versinkt.
    // Wasser steigt bis ~ +1.6 Units (200 cm Sealevel) → Planken bei 0.85 sind
    // bis ca. 100 cm Anstieg sicher.
    const plankY = 0.85
    const plankCount = 7
    for (let i = 0; i < plankCount; i++) {
      const t = i / (plankCount - 1)
      const z = pierStartZ + t * (pierEndZ - pierStartZ)
      const plank = new THREE.Mesh(
        new THREE.BoxGeometry(0.85, 0.07, 0.45),
        i % 2 === 0 ? woodMat : woodMatDark,
      )
      plank.position.set(pierX, plankY, z)
      plank.castShadow = true
      plank.receiveShadow = true
      group.add(plank)
    }

    // === RAMPE: schiefe Holz-Ebene vom Land aufs Steg-Niveau ===
    // Geht vom hohen Inland-Punkt (deutlich über Wasser) hoch zur Plankenkante.
    // Damit ist der Steg auch bei starkem Meeresspiegelanstieg noch trocken
    // erreichbar — die Rampe selbst wird vom Wasser nicht überspült, weil sie
    // ja auf dem Inselhang sitzt.
    // Wir nehmen einen sicheren Inland-Punkt etwa 3 Units weiter im Z drin
    // und holen die echte Terrainhöhe dort ab.
    const rampInlandZ = pierStartZ - 3.0       // tiefer in der Insel
    const rampSeaZ = pierStartZ - 0.05         // direkt am Plankenanfang
    const rampInlandY = this.terrainHeight(pierX, rampInlandZ) + 0.05
    const rampSeaY = plankY                    // schließt bündig an die Planken
    const rampLengthZ = rampSeaZ - rampInlandZ
    const rampLengthY = rampSeaY - rampInlandY
    const rampLen = Math.sqrt(rampLengthZ * rampLengthZ + rampLengthY * rampLengthY)
    const rampAngle = Math.atan2(rampLengthY, rampLengthZ)

    // Hauptbalken (eine schräge Box)
    const ramp = new THREE.Mesh(
      new THREE.BoxGeometry(0.85, 0.08, rampLen),
      woodMat,
    )
    // Mittelpunkt der Rampe
    ramp.position.set(
      pierX,
      (rampInlandY + rampSeaY) / 2,
      (rampInlandZ + rampSeaZ) / 2,
    )
    // Negative Rotation um X-Achse → Z-Achse kippt nach oben in Richtung Steg
    ramp.rotation.x = -rampAngle
    ramp.castShadow = true
    ramp.receiveShadow = true
    group.add(ramp)

    // Querlatten auf der Rampe (Anti-Rutsch-Optik), gleichmäßig verteilt
    const rungs = 5
    for (let i = 1; i < rungs; i++) {
      const t = i / rungs
      const rz = rampInlandZ + t * rampLengthZ
      const ry = rampInlandY + t * rampLengthY
      const rung = new THREE.Mesh(
        new THREE.BoxGeometry(0.88, 0.025, 0.10),
        woodMatDark,
      )
      rung.position.set(pierX, ry + 0.045, rz)
      rung.rotation.x = -rampAngle
      group.add(rung)
    }

    // 4 Holzpfähle — länger, damit sie unter dem höheren Plankenweg den
    // Boden im Wasser erreichen
    const postH = 1.5
    const postCenterY = plankY - postH / 2 - 0.05
    for (const z of [pierStartZ + 0.4, pierStartZ + 1.6, pierStartZ + 2.7, pierEndZ - 0.1]) {
      for (const dx of [-0.32, 0.32]) {
        const post = new THREE.Mesh(
          new THREE.CylinderGeometry(0.07, 0.085, postH, 6),
          woodMatDark,
        )
        post.position.set(pierX + dx, postCenterY, z)
        post.castShadow = true
        group.add(post)
      }
    }

    // Geländer (etwas höher und länger)
    for (const z of [pierStartZ + 0.8, pierStartZ + 2.2]) {
      for (const dx of [-0.36, 0.36]) {
        const rail = new THREE.Mesh(
          new THREE.CylinderGeometry(0.028, 0.028, 0.42, 5),
          woodMatDark,
        )
        rail.position.set(pierX + dx, plankY + 0.24, z)
        group.add(rail)
      }
    }

    this.scene.add(group)
    this.pierGroup = group
    // Bootsanlegestelle: vor dem Steg-Ende, knapp über Wasserlinie
    this.pierEndPos.set(pierX, WATER_Y_BASE + 0.22, pierEndZ - 0.1)

    // === Wartende Bewohner-Sprites ===
    // Ganze stehende Menschen (nicht nur Köpfe!) als Emoji-Billboards.
    // 🧍 (person standing) ist ein Single-Codepoint-Emoji ohne ZWJ → rendert
    // verlässlich auf der Canvas, im Gegensatz zu 🧍‍♂️ / 🧍‍♀️.
    const waiters = ['🧍', '🚶', '🧍']
    const figureSize = 0.55
    // Sprite-Mitte sitzt halbe Höhe über den Planken, sodass die Füße
    // optisch auf dem Steg stehen.
    const figureY = plankY + figureSize / 2 + 0.02
    for (let i = 0; i < 3; i++) {
      const sprite = this.makeEmojiSprite(waiters[i], figureSize)
      const rowZ = pierStartZ + 1.0 + i * 0.55
      sprite.position.set(pierX + (i % 2 === 0 ? -0.12 : 0.12), figureY, rowZ)
      this.scene.add(sprite)
      this.waitingFigures.push(sprite)
    }
  }

  /**
   * Insel-Leben: kleine statische Sprites + Modelle, die das Bild beleben.
   *
   *   - ☁️ 4 Wolken am Himmel — werden bei Hitze dunkler / mehr Sturm
   *   - 🐦 6 Möwen am Strand — verschwinden nacheinander ab Temp >17°C
   *   - ⛵ 1 ankerndes Fischerboot in der Bucht — verschwindet bei Tourismus
   *
   * Alles statisch (keine pro-Frame-Update-Logik außer Sichtbarkeit).
   */
  private buildAmbientLife(): void {
    // === Wolken auf Vulkan-Höhe ===
    // Die Wolken hängen an der Spitze des Vulkans und daneben — wirkt natürlich
    // (Bergwolken um Gipfel) und macht den Vulkan zum Himmels-Anker.
    const volcanoPeakY = this.terrainHeight(VOLCANO_X, VOLCANO_Z) - VOLCANO_DOME_SINK + VOLCANO_DOME_HEIGHT
    const cloudY = volcanoPeakY + 1.2  // knapp über der Spitze
    const cloudPositions: Array<[number, number, number]> = [
      [VOLCANO_X - 1.5, cloudY + 0.4, VOLCANO_Z + 0.5],
      [VOLCANO_X + 2.0, cloudY,        VOLCANO_Z - 1.0],
      [VOLCANO_X + 0.3, cloudY + 1.1,  VOLCANO_Z - 2.5],
      [VOLCANO_X - 4.0, cloudY - 0.3,  VOLCANO_Z + 2.0],
      [VOLCANO_X + 4.5, cloudY + 0.6,  VOLCANO_Z + 1.5],
    ]
    for (const [cx, cy, cz] of cloudPositions) {
      const cloud = this.makeEmojiSprite('☁️', 2.4)
      cloud.position.set(cx, cy, cz)
      this.scene.add(cloud)
      this.ambientClouds.push(cloud)
    }

    // === Statische Boden-Sprites über der Insel ===
    // KEINE schwebenden Tiere (Vögel, Schmetterlinge) — die sehen ohne
    // Animation komisch aus. Stattdessen NUR Sachen, die natürlich am
    // Boden sitzen: Tiere auf der Wiese, Blumen, Felsen, Pilze.
    //
    // Helper: Y-Position aus dem Terrain auslesen, sodass Sprite auf dem
    // Boden steht (Sprite-Mitte = Bodenhöhe + halbe Sprite-Größe).
    const placeOnGround = (x: number, z: number, size: number): number =>
      this.terrainHeight(x, z) + size / 2 + 0.02

    // Bewusst kleine Auswahl — die 3D-Seite ist schon ausgelastet,
    // wir vermeiden zu viele Sprites. Schwerpunkt: ein paar Tiere
    // auf der Wiese, ein paar Blumen, ein paar Felsen.
    const atmoSprites: Array<{ emoji: string; x: number; z: number; size: number }> = [
      // Blumen
      { emoji: '🌸', x: -5, z: -3, size: 0.42 },
      { emoji: '🌻', x:  3, z: -5, size: 0.45 },
      { emoji: '🌷', x: -8, z:  1, size: 0.42 },
      { emoji: '🌼', x:  6, z:  4, size: 0.42 },
      // Tiere (alle auf dem Boden)
      { emoji: '🐑', x: -2, z:  4, size: 0.55 },
      { emoji: '🐄', x: -6, z:  3, size: 0.62 },
      { emoji: '🐄', x:  5, z:  6, size: 0.62 },
      { emoji: '🐰', x:  7, z: -2, size: 0.42 },
      // Felsen
      { emoji: '🪨', x: -11, z: -3, size: 0.42 },
      { emoji: '🪨', x:  11, z:  4, size: 0.42 },
    ]
    for (const s of atmoSprites) {
      // Maßnahmen-Bereiche und Vulkan überspringen
      if (this.isInVolcanoArea(s.x, s.z, 0.5)) continue
      const y = this.terrainHeight(s.x, s.z)
      if (y < 0.5) continue  // zu tief (Strand/Wasser)
      const sprite = this.makeEmojiSprite(s.emoji, s.size)
      sprite.position.set(s.x, placeOnGround(s.x, s.z, s.size), s.z)
      this.scene.add(sprite)
    }

    // Sonne als einziges schwebendes Himmels-Element
    const sun = this.makeEmojiSprite('☀️', 3.0)
    sun.position.set(22, 24, -8)
    this.scene.add(sun)

    // === Möwen am Strand ===
    // KEINE schwebenden Vögel — Möwen stehen direkt auf dem Sand.
    // Sparsam: 4 Möwen reichen, Performance.
    const gullSpots: Array<[number, number]> = [
      [-8,  ISLAND_HALF_LENGTH * 0.85],
      [ 5,  ISLAND_HALF_LENGTH * 0.88],
      [ 9,  ISLAND_HALF_LENGTH * 0.82],
      [-11, ISLAND_HALF_LENGTH * 0.75],
    ]
    const gullSize = 0.32
    for (const [gx, gz] of gullSpots) {
      if (Math.abs(gx - 1.2) < 1.5 && gz > ISLAND_HALF_LENGTH * 0.7) continue
      const y = this.terrainHeight(gx, gz)
      if (y < 0.05) continue  // im Wasser
      const gull = this.makeEmojiSprite('🐦', gullSize)
      // Möwe steht auf dem Sand: Y = Boden + halbe Sprite-Höhe
      gull.position.set(gx, y + gullSize / 2 + 0.01, gz)
      this.scene.add(gull)
      this.ambientGulls.push(gull)
    }

    // === Ankerndes Fischerboot in der Bucht ===
    // Klein, statisch, leicht zur Seite vom Steg, im Wasser.
    const boatGroup = new THREE.Group()
    const hull = new THREE.Mesh(
      new THREE.BoxGeometry(1.4, 0.32, 0.65),
      new THREE.MeshStandardMaterial({ color: 0x8a5a2a, roughness: 1 }),
    )
    hull.position.y = 0.16
    boatGroup.add(hull)
    const bow = new THREE.Mesh(
      new THREE.BoxGeometry(0.35, 0.26, 0.5),
      new THREE.MeshStandardMaterial({ color: 0x7a4a1a, roughness: 1 }),
    )
    bow.position.set(0.78, 0.18, 0)
    boatGroup.add(bow)
    // Mast + Segel
    const fishMast = new THREE.Mesh(
      new THREE.CylinderGeometry(0.035, 0.035, 0.95, 6),
      new THREE.MeshStandardMaterial({ color: 0x6a4a2a, roughness: 1 }),
    )
    fishMast.position.set(-0.1, 0.65, 0)
    boatGroup.add(fishMast)
    const fishSail = new THREE.Mesh(
      new THREE.BoxGeometry(0.65, 0.7, 0.012),
      new THREE.MeshStandardMaterial({ color: 0xe2d8b8, roughness: 1, side: THREE.DoubleSide }),
    )
    fishSail.position.set(0.05, 0.65, 0)
    fishSail.rotation.y = Math.PI / 2
    boatGroup.add(fishSail)
    // Fischerei-Sprite-Emoji als Kennzeichen
    const fishEmoji = this.makeEmojiSprite('🐟', 0.32)
    fishEmoji.position.set(-0.5, 0.42, 0.0)
    boatGroup.add(fishEmoji)
    // Position im Wasser, links vom Steg
    boatGroup.position.set(-3.5, WATER_Y_BASE + 0.14, ISLAND_HALF_LENGTH * 0.78 + 2.2)
    boatGroup.rotation.y = -Math.PI / 2 - 0.15
    this.scene.add(boatGroup)
    this.ambientFishingBoat = boatGroup
  }

  /**
   * Lawinen-Schutzwall am Vulkan-Fuß. Wird einmalig gebaut, sobald
   * `game.hasMountainShield` true ist. Mehrere Mauern-Segmente, die im
   * Bogen unter dem Vulkan-Hang hervorragen.
   */
  private buildMountainShield(): void {
    const group = new THREE.Group()
    const wallMat = new THREE.MeshStandardMaterial({ color: 0x8a8782, roughness: 1 })
    const capMat  = new THREE.MeshStandardMaterial({ color: 0x6a6862, roughness: 1 })
    const segments = 5
    const arcRadius = VOLCANO_DOME_RADIUS + 1.8
    // Bogen vor dem Vulkan, der zur "Tal-Seite" (positive Z) zeigt
    const baseAngle = Math.PI / 2
    const arcSpread = Math.PI * 0.55  // ~ 100°
    for (let i = 0; i < segments; i++) {
      const t = (i / (segments - 1)) - 0.5
      const angle = baseAngle + t * arcSpread
      const x = VOLCANO_X + Math.cos(angle) * arcRadius
      const z = VOLCANO_Z + Math.sin(angle) * arcRadius
      const yGround = this.terrainHeight(x, z)
      const seg = new THREE.Group()
      const wall = new THREE.Mesh(
        new THREE.BoxGeometry(0.85, 0.7, 0.22),
        wallMat,
      )
      wall.position.y = 0.35
      wall.castShadow = true
      wall.receiveShadow = true
      seg.add(wall)
      const cap = new THREE.Mesh(
        new THREE.BoxGeometry(0.90, 0.07, 0.27),
        capMat,
      )
      cap.position.y = 0.74
      seg.add(cap)
      // Tangentiale Ausrichtung (parallel zum Bogen)
      seg.rotation.y = -angle
      seg.position.set(x, Math.max(0.05, yGround), z)
      group.add(seg)
    }
    this.scene.add(group)
    this.mountainShieldGroup = group
  }

  // ============================================================
  // Update + Draw
  // ============================================================

  private updateScene(dt: number): void {
    // Lawinen-Schutzwall am Vulkanfuß: einmalig bauen, sobald gewählt
    if (!this.mountainShieldGroup && (this.game as any).hasMountainShield) {
      this.buildMountainShield()
    }

    // Neue gebaute Maßnahmen einfügen
    const owned = this.game.getOwnedMeasures()
    for (const m of owned) {
      for (let i = 0; i < m.count; i++) {
        const id = `${m.measureId}-${i}`
        if (!this.knownIds.has(id)) {
          this.knownIds.add(id)
          // Position aus dem OwnedMeasure lesen — wenn vorhanden, dort platzieren
          const pos = m.positions?.[i]
          const mesh = this.placeMeasure(m.measureId, i, pos || undefined)
          if (mesh) {
            this.placed.push({ ownerId: id, mesh, typeId: m.measureId })
          }
        }
      }
    }
    // Abgerissene Maßnahmen aus der Szene entfernen.
    // Pro typeId: erlaubt sind die ersten N Indizes (N = aktueller count).
    // Alles darüber wurde demoliert und muss verschwinden.
    const allowedByType = new Map<string, number>()
    for (const m of owned) allowedByType.set(m.measureId, m.count)
    for (let i = this.placed.length - 1; i >= 0; i--) {
      const p = this.placed[i]
      const allowedCount = allowedByType.get(p.typeId) ?? 0
      // ownerId hat Form "typeId-INDEX", wir parsen den Index
      const idxStr = p.ownerId.split('-').pop() || '0'
      const idx = parseInt(idxStr, 10) || 0
      if (idx >= allowedCount) {
        this.scene.remove(p.mesh)
        // tiefes Aufräumen (Geometrien/Materialien)
        p.mesh.traverse((obj) => {
          const mesh = obj as THREE.Mesh
          if (mesh.geometry) mesh.geometry.dispose?.()
          if (mesh.material) {
            const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
            mats.forEach(mat => mat.dispose?.())
          }
        })
        this.knownIds.delete(p.ownerId)
        this.placed.splice(i, 1)
      }
    }

    // Rotoren drehen
    for (const p of this.placed) {
      if (p.typeId === 'wind') {
        const rotor = (p.mesh as any).userData?.rotorRef
        if (rotor) rotor.rotation.z = this.t * 1.4
      }
    }

    // Drohnenflug fortsetzen, wenn keine User-Aktivität
    if (!this.droneActive && this.t >= this.droneResumeAt && !this.isDragging) {
      this.droneActive = true
    }
    if (this.droneActive) {
      this.camTheta += dt * 0.06   // sehr langsam
    }

    // Meeresspiegel: das WASSER steigt immer, unabhängig von Deichen.
    // Deiche schützen einzelne Objekte (Häuser), erhöhen aber nicht den Boden.
    const sealevel = this.game.getResource('sealevel')
    const waterY = WATER_Y_BASE + (sealevel / 200) * MAX_SEA_RISE_UNITS
    this.water.position.y = waterY
    // Schutzhöhe der Deiche (nur für Sub-Mergence-Check der Häuser)
    const protection = (this.game as any).variables?.protection || 0
    const protectionY = (protection / 200) * MAX_SEA_RISE_UNITS

    // Klimastress färbt Himmel und Insel
    const temp = this.game.getResource('temperature')
    const co2 = this.game.getResource('co2')
    const tempStress = Math.max(0, Math.min(1, (temp - 15) / 4))
    const co2Stress = Math.max(0, Math.min(1, (co2 - 425) / 350))

    // === Gletscher-Schmelze via Clipping-Plane ===
    // 15.0°C → 100% Schnee, 18.0°C → 0% (nur Spitze / ganz weg).
    const snowFraction = Math.max(0, Math.min(1, 1 - (temp - 15) / 3))
    // Die Clipping-Ebene wandert von der Schnee-Basis nach oben durch
    // die gesamte Kuppelhöhe. Alles UNTER der Ebene wird weggeclippt.
    // Bei snowFraction=1 → Ebene unter der Basis (alles sichtbar)
    // Bei snowFraction=0 → Ebene über der Spitze (alles weg)
    const meltY = this.snowBaseWorldY + (1 - snowFraction) * (VOLCANO_DOME_HEIGHT + 0.3)
    // Plane-Formel: n·p + constant = 0 → für n=(0,1,0) gilt: constant = -meltY
    // Sichtbar ist alles, wo n·p + constant >= 0 → also y >= meltY
    this.snowClipPlane.constant = -meltY

    // Schnee verschwindet ganz wenn Fraction unter 1%
    this.glacierIce.visible = snowFraction > 0.01
    // Schnee wird leicht grauer/dreckiger bei Schmelze
    const iceMat = this.glacierIce.material as THREE.MeshStandardMaterial
    const grime = (1 - snowFraction) * 0.16
    iceMat.color.setRGB(0.98 - grime, 0.99 - grime, 1.00 - grime)

    // Himmelsfarbe
    const skyR = 188 + co2Stress * 40
    const skyG = 213 - co2Stress * 30
    const skyB = 230 - co2Stress * 60
    ;(this.sky.material as THREE.MeshBasicMaterial).color.setRGB(skyR / 255, skyG / 255, skyB / 255)
    this.scene.background = (this.sky.material as THREE.MeshBasicMaterial).color

    // Insel-Tönung durch Klimastress: Emissive leicht orange bei Hitze
    // (Vertex-Farben bleiben erhalten, aber Material strahlt wärmer)
    const islMat = this.island.material as THREE.MeshStandardMaterial
    islMat.color.setRGB(
      1.0 + tempStress * 0.25,   // leicht warmer Tint
      1.0 - tempStress * 0.05,
      1.0 - tempStress * 0.15,
    )

    // Wasserfarbe — bleibt blau, wird nur leicht heller bei Hitze
    const watR = 42 + tempStress * 15
    const watG = 120 + tempStress * 10
    const watB = 184 - tempStress * 20
    ;(this.water.material as THREE.MeshStandardMaterial).color.setRGB(watR / 255, watG / 255, watB / 255)

    // === Insel-Leben reagiert auf Klima/State ===
    // Möwen verschwinden ab Temperatur >17°C nacheinander.
    // Bei 17°C → 0% sichtbar, bei 16.5°C → 100%.
    const gullsAlive = Math.max(0, Math.min(1, 1 - (temp - 16.5) / 0.7))
    const gullsToShow = Math.round(this.ambientGulls.length * gullsAlive)
    for (let i = 0; i < this.ambientGulls.length; i++) {
      this.ambientGulls[i].visible = i < gullsToShow
    }
    // Wolken werden bei Hitze leicht grauer (Sturmstimmung)
    for (const cloud of this.ambientClouds) {
      const m = cloud.material as THREE.MeshBasicMaterial
      m.color.setRGB(1 - tempStress * 0.25, 1 - tempStress * 0.25, 1 - tempStress * 0.18)
    }
    // Fischerboot verschwindet, sobald Tourismus-Modus aktiv wurde
    if (this.ambientFishingBoat) {
      const tourism = (this.game as any).tourismMode === true
      // Auch wenn das Boot im Wasser steht und der Wasserspiegel steigt:
      // mitschwimmen lassen, damit es nicht versinkt.
      this.ambientFishingBoat.position.y = waterY + 0.14
      this.ambientFishingBoat.visible = !tourism
    }

    // Untergetauchte Objekte ausblenden.
    // Häuser sind durch Deiche/Mauern um protectionY angehoben geschützt.
    const submergeY = waterY - 0.05
    const houseSubmergeY = submergeY - protectionY
    // Sand-Erosion: für jede Sand-Aufschüttung das Alter aus dem game-State
    // lesen und den Mesh-Hügel entsprechend einsinken lassen.
    const ownedNow = this.game.getOwnedMeasures()
    let sandIndex = 0
    for (const p of this.placed) {
      // Deiche/Mauern selbst gehen nicht unter (sie sollen ja Schutz bieten)
      if (p.typeId === 'dike' || p.typeId === 'sea-wall') {
        p.mesh.visible = true
        continue
      }
      if (p.typeId === 'sand-fill') {
        // Pro Sand-Mesh den passenden owned-Eintrag finden (jede Instanz hat
        // einen eigenen owned-Eintrag, weil buyMeasure für 'sand-fill' immer
        // neue Gruppen anlegt). Wir nehmen sie der Reihe nach.
        const sandOwneds = ownedNow.filter(o => o.measureId === 'sand-fill')
        const owned = sandOwneds[sandIndex]
        sandIndex++
        if (owned) {
          const ageYears = Math.max(0, this.game.getSnapshot().tick - owned.builtAt)
          // 9 Jahre = ganz weg (Game halbiert die Schutz-Wirkung mit 2 cm/Jahr aus 18 cm)
          const remaining = Math.max(0.05, 1 - ageYears / 9)
          p.mesh.scale.y = remaining
          // Sand bleibt sichtbar, schmilzt nur visuell
          p.mesh.visible = true
        }
        continue
      }
      p.mesh.visible = p.mesh.position.y >= houseSubmergeY
    }
    // Auch Dorf-Häuser können absaufen — durch Deiche aber später
    for (const child of this.villageGroup.children) {
      child.visible = child.position.y >= houseSubmergeY
    }

    // === Bevölkerungs-Sprung → Flüchtlingsboot ===
    const currentPop = this.game.getResource('population')
    if (currentPop < this.lastPopulation - 60) {
      // Ein größerer Sprung nach unten → Boot starten
      this.spawnRefugeeBoat()
      this.lastPopulation = currentPop
    } else if (currentPop > this.lastPopulation) {
      this.lastPopulation = currentPop
    }

    // === FX-Effekte aktualisieren ===
    // Billboards (Totenköpfe) zur Kamera ausrichten
    for (let i = this.fxEffects.length - 1; i >= 0; i--) {
      const ef = this.fxEffects[i]
      const alive = ef.update(dt, ef)
      if (!alive) {
        this.fxEffects.splice(i, 1)
        continue
      }
      // Billboards: Orientierung zur Kamera
      ef.root.traverse((obj) => {
        if ((obj as any).userData?.isBillboard) {
          obj.lookAt(this.camera.position)
        }
      })
    }

    // === Baum-Gesundheit nach Typ (3 Stadien) ===
    // alive:  grün, voller Baum
    // dying:  braun, Blätter noch da
    // dead:   schwarzer Strunk, KEINE Blätter mehr (werden unsichtbar)
    const treeHealth = (kind: 'palm' | 'leaf' | 'conifer'): number => {
      let tStart: number, tDead: number
      if (kind === 'conifer')   { tStart = 15.8; tDead = 17.3 }
      else if (kind === 'leaf') { tStart = 16.3; tDead = 17.8 }
      else                      { tStart = 17.0; tDead = 18.2 }  // palm
      if (temp <= tStart) return 1
      if (temp >= tDead) return 0
      return 1 - (temp - tStart) / (tDead - tStart)
    }
    for (const tree of this.trees) {
      const health = treeHealth(tree.kind)
      const ud = (tree.mesh as any).userData
      const leafMat = ud?.leafMat as THREE.MeshStandardMaterial | undefined
      const trunkMat = ud?.trunkMat as THREE.MeshStandardMaterial | undefined
      if (!leafMat) continue

      // Stadium bestimmen
      let nextStage: 'alive' | 'dying' | 'dead'
      if (health > 0.55) nextStage = 'alive'
      else if (health > 0.05) nextStage = 'dying'
      else nextStage = 'dead'

      // Grundfarbe je Typ
      let gR = 0.33, gG = 0.60, gB = 0.29
      if (tree.kind === 'palm')    { gR = 0.29; gG = 0.63; gB = 0.33 }
      if (tree.kind === 'conifer') { gR = 0.22; gG = 0.42; gB = 0.22 }
      const brownR = 0.48, brownG = 0.32, brownB = 0.14
      const blackR = 0.12, blackG = 0.08, blackB = 0.05

      if (nextStage === 'alive') {
        // Farbübergang grün → braun
        const t = (health - 0.55) / 0.45  // 0..1 innerhalb alive
        leafMat.color.setRGB(
          lerp(brownR, gR, t),
          lerp(brownG, gG, t),
          lerp(brownB, gB, t),
        )
        // Blätter + Stamm sichtbar
        tree.mesh.children.forEach(c => (c.visible = true))
      } else if (nextStage === 'dying') {
        // Voll braun, Blätter hängen noch
        leafMat.color.setRGB(brownR, brownG, brownB)
        tree.mesh.children.forEach(c => (c.visible = true))
      } else {
        // Tot: Baum kippt um und liegt am Boden, sinkt langsam ein.
        // Stamm → dunkelbraun/schwarz, Blätter → braun dann unsichtbar
        if (trunkMat) {
          trunkMat.color.setRGB(blackR, blackG, blackB)
        }

        // Umkipp-Animation: rotation.z dreht sich auf ~PI/2 (liegt auf der Seite)
        if (!ud.fallStarted) {
          ud.fallStarted = true
          ud.fallProgress = 0
          ud.fallDir = Math.random() > 0.5 ? 1 : -1
          ud.sinkProgress = 0
        }
        // Umfallen (schnell, ~2s bei 60fps Render-Calls)
        if (ud.fallProgress < 1) {
          ud.fallProgress = Math.min(1, ud.fallProgress + 0.02)
          // Ease-out Kurve fuer natuerliches Fallen
          const t = 1 - Math.pow(1 - ud.fallProgress, 2)
          tree.mesh.rotation.z = t * (Math.PI / 2) * ud.fallDir * 0.85
        } else {
          // Liegt am Boden → langsam einsinken
          ud.sinkProgress = Math.min(1, (ud.sinkProgress || 0) + 0.001)
          tree.mesh.position.y = tree.anchorY - ud.sinkProgress * 0.4
        }

        // Blaetter ausblenden nach dem Fallen
        const leafFade = Math.max(0, 1 - (ud.fallProgress || 0) * 1.5)
        tree.mesh.children.forEach(c => {
          const cm = (c as THREE.Mesh)
          if (cm.material === leafMat) {
            c.visible = leafFade > 0.05
            leafMat.opacity = leafFade
            leafMat.transparent = true
          } else {
            c.visible = true
          }
        })
        tree.mesh.scale.setScalar(0.92)
      }

      // Übergang von alive/dying → dead: Totenkopf-Effekt einmalig
      if (tree.stage !== 'dead' && nextStage === 'dead') {
        this.spawnSkull(tree.mesh.position.x, tree.mesh.position.y + 0.5, tree.mesh.position.z)
      }
      tree.stage = nextStage
    }

    // === Bäume verheizen bei Stromausfall ===
    // Das Game zählt einen kumulativen Counter `treesChoppedForHeat`.
    // Sobald er größer ist als das, was der Renderer schon getötet hat,
    // wählen wir die nächsten lebendigen Bäume aus und setzen sie hart auf 'dead'.
    // Der Stamm bleibt sichtbar (schwarzer Strunk), Blätter verschwinden,
    // ein Totenkopf-Sprite signalisiert die Fällung.
    const choppedTotal = (this.game as any).treesChoppedForHeat || 0
    if (choppedTotal > this.lastChoppedRendered) {
      const need = choppedTotal - this.lastChoppedRendered
      let killed = 0
      for (const tree of this.trees) {
        if (killed >= need) break
        if (tree.stage === 'dead') continue
        // Hart als 'dead' markieren — die nächste Frame der oberen Schleife
        // zeichnet ihn im finalen Stadium (schwarzer Strunk).
        const ud = (tree.mesh as any).userData
        const leafMat = ud?.leafMat as THREE.MeshStandardMaterial | undefined
        const trunkMat = ud?.trunkMat as THREE.MeshStandardMaterial | undefined
        if (leafMat && trunkMat) {
          trunkMat.color.setRGB(0.12, 0.08, 0.05)
          tree.mesh.children.forEach(c => {
            const cm = c as THREE.Mesh
            if (cm.material === leafMat) c.visible = false
          })
          tree.mesh.scale.setScalar(0.92)
        }
        tree.stage = 'dead'
        this.spawnSkull(tree.mesh.position.x, tree.mesh.position.y + 0.5, tree.mesh.position.z)
        killed++
      }
      this.lastChoppedRendered = choppedTotal
    }

    // === Heizwolken: individuell animierte Puffs pro Haus ===
    // Jede Solar- oder Gründach-Anlage deckt ein Haus ab.
    const solarCount = this.game.getMeasureCount('solar')
    const greenRoofCount = this.game.getMeasureCount('green-roof')
    const totalCovered = solarCount + greenRoofCount
    const housesWithSmoke = Math.max(0, this.houseAnchors.length - totalCovered)
    // Globale Windrichtung (langsam wechselnd)
    const globalWind = Math.sin(this.t * 0.3) * 0.6 + 0.4

    for (let i = 0; i < this.smokePuffs.length; i++) {
      const anchor = this.houseAnchors[i]
      const houseAlive = anchor.y >= submergeY
      const shouldSmoke = houseAlive && i < housesWithSmoke

      for (const puff of this.smokePuffs[i]) {
        puff.mesh.visible = shouldSmoke
        if (!shouldSmoke) continue
        // Altern
        puff.age += dt
        if (puff.age > puff.lifetime) {
          // Neue Runde — klein und unten neu starten
          puff.age = 0
          puff.speedY = 0.35 + Math.random() * 0.25
          puff.windAmp = 0.2 + Math.random() * 0.25
          puff.windPhase = Math.random() * Math.PI * 2
        }
        const t01 = puff.age / puff.lifetime  // 0..1
        // Aufstieg
        const y = anchor.y + 0.05 + t01 * puff.speedY * puff.lifetime
        // Wind-Drift (horizontal) — je älter desto weiter
        const drift = Math.sin(puff.windPhase + this.t * 0.8) * puff.windAmp * t01
        const xOff = drift * globalWind
        const zOff = drift * 0.5
        // Größe wächst mit Alter, Opacity sinkt am Ende
        const growth = 1 + t01 * 1.8
        const opacity = (1 - t01) * 0.65
        puff.mesh.position.set(anchor.x + xOff, y, anchor.z + zOff)
        puff.mesh.scale.setScalar(growth)
        ;(puff.mesh.material as THREE.MeshStandardMaterial).opacity = opacity
      }
    }
  }

  private applyCamera(): void {
    const sinPhi = Math.sin(this.camPhi)
    const cosPhi = Math.cos(this.camPhi)
    const x = Math.sin(this.camTheta) * sinPhi * this.camRadius
    const y = cosPhi * this.camRadius
    const z = Math.cos(this.camTheta) * sinPhi * this.camRadius
    this.camera.position.set(x, y, z)
    this.camera.lookAt(0, 0.5, 0)
  }

  private draw(): void {
    this.applyCamera()
    this.renderer.render(this.scene, this.camera)
  }
}
