Zum Inhalt
← 3D & WebGL

Spielbare 3D-Welt als Navigation

Die Kieler Förde als kleine Spielwelt: Mit dem Vereinsboot fährt man fünf Projektorte an, jede Boje öffnet eine Wirkungskarte, und alle Inhalte gibt es auch als 2D-Liste.

Die Seite ist eine kleine 3D-Welt, etwa eine Insel, ein Planet, ein Raum oder eine Stadt. Man steuert ein Fahrzeug oder eine Figur mit Tastatur oder Touch, oder klickt Orte an. Einfache Physik sorgt für Kollisionen und Spielzeug-Gefühl. Die Inhalte sind Orte in der Welt. Ein klassisches Menü oder eine Liste führt als Abkürzung direkt dorthin. Stilisiertes Rendering mit Toon-Shading, Matcaps oder gebackenem Licht spart Leistung und gibt Charme. Ton ist optional.

Besucher bleiben länger und erzählen davon weiter. Die Welt zeigt Persönlichkeit und Können und macht das Erkunden selbst zum Erlebnis.

Wann einsetzen

Für persönliche Portfolios von Kreativen und Entwickler:innen, Kampagnenseiten, Spielzeug- und Gaming-Marken und Projekte für Kinder.

Wann nicht

Für Kund:innen, die schnell Informationen brauchen, wenn es keine Abkürzung zu den Inhalten gibt. Nicht als einzige Startseite, wenn Besucher etwas erledigen sollen.

Varianten

  • Fahrbares Fahrzeug durch die Welt (wie das Auto-Portfolio von Bruno Simon)
  • Kleine Planetenwelt (Messenger von Abeto)
  • Spielzeug-Eisenbahnwelt (Choo Choo World von Lusion)
  • Physik-Charakter-Controller mit Rapier in React Three Fiber
  • Retro-Konsolen-Konzept mit Boot-Sequenz (Goodgrowth)

Umsetzung

Licht in Blender backen, Matcaps oder Toon-Shading statt PBR, wenige Draw Calls durch Zusammenfassen und Instancing. Bei längerer Ladezeit einen Ladebildschirm mit Fortschritt zeigen. Immer eine 2D-Abkürzung zu allen Inhalten anbieten, am besten als echte HTML-Liste, die auch die Inhalte der Orte trägt.

Robust umsetzen

Telefon. Touch-Joystick oder Tippen zum Hinfahren, dazu eine vereinfachte Welt.

Ohne Bewegung. Kamera ohne Schwenks und ohne Wackeln. Eine 2D-Ansicht anbieten.

Barrierefrei. Alle Inhalte zusätzlich als vollständiges HTML. Tastatursteuerung sichtbar erklären. Ton nur nach Zustimmung per Klick. Die Spielfläche bekommt Fokus und eine Beschriftung.

Code

<!DOCTYPE html>
<!--
  Spielbare 3D-Welt als Navigation
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/spielbare-3d-welt/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spielbare 3D-Welt als Navigation · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Big Shoulders'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/big-shoulders:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Archivo'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/archivo:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-stretch: 62% 125%; font-display: swap; }

:root {
  /* Rezept verein-nonprofit-wirkung: tiefer Markenton, helle Fläche derselben Farbe, warmes Grau, Signal nur für „Spenden“ */
  --weiss: #fff; --hell: #edf5fa; --marke: #002e6d; --tinte: #222520; --grau: #dfdfd7; --signal: #ffca0a;
  --kurve: cubic-bezier(0.645, 0.045, 0.355, 1);   /* easeInOutCubic wie The Ocean Cleanup */
  --ui: 150ms;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--hell); color: var(--tinte); font: 400 1.0625rem/1.6 'Archivo', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; }
.display { font-family: 'Big Shoulders', 'Archivo', sans-serif; font-weight: 800; text-transform: uppercase; letter-spacing: .005em; font-variant-numeric: tabular-nums; }
.label { font: 600 .75rem/1.3 'Archivo', sans-serif; letter-spacing: .12em; text-transform: uppercase; color: var(--marke); }
.knopf { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 10px 18px; border-radius: 4px; font: 600 1rem/1.2 'Archivo', sans-serif; text-align: center; text-decoration: none; border: 2px solid var(--marke); color: var(--marke); background: var(--weiss); cursor: pointer; transition: background-color var(--ui) linear, color var(--ui) linear; }
.knopf:hover { background: var(--marke); color: var(--weiss); }
.spenden { background: var(--signal); border-color: var(--signal); color: var(--tinte); }
.spenden:hover { background: #ffd43b; color: var(--tinte); }
:focus-visible { outline: 3px solid var(--marke); outline-offset: 2px; }

/* ---------- Welt ---------- */
.welt { position: relative; height: 100svh; min-height: 660px; overflow: hidden; background: var(--hell); }
.leinwand { position: absolute; inset: 0; }
.leinwand canvas { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: crosshair; }
.leinwand:focus-visible { outline: 3px solid var(--marke); outline-offset: -3px; }
.ersatz { display: none; position: absolute; inset: 0; background:
  radial-gradient(40% 30% at 12% 50%, var(--grau) 98%, transparent 100%),
  radial-gradient(34% 28% at 92% 52%, var(--grau) 98%, transparent 100%),
  linear-gradient(180deg, #b8d8ea, #9cc8e2); }
.ohne-webgl .ersatz { display: block; }

.kopf { position: absolute; inset: 0 0 auto; z-index: 4; display: flex; align-items: center; gap: 28px; padding: 18px clamp(16px, 3vw, 40px); background: linear-gradient(180deg, rgba(237, 245, 250, .92), rgba(237, 245, 250, 0)); }
.logo { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--marke); }
.logo svg { width: 30px; height: 30px; }
.logo b { font: 800 1.6rem/1 'Big Shoulders', sans-serif; text-transform: uppercase; letter-spacing: .01em; }
.kopf nav { display: flex; gap: 24px; font-weight: 500; font-size: .9375rem; }
.kopf nav a { text-decoration: none; color: var(--marke); transition: opacity var(--ui) linear; }
.kopf nav a:hover { opacity: .7; }
.kopf .spenden { margin-left: auto; }

.intro { position: absolute; z-index: 3; left: clamp(16px, 3vw, 40px); top: clamp(88px, 12vh, 120px); max-width: min(560px, 44vw); pointer-events: none; }
.intro .hinweis { pointer-events: auto; }
.intro :is(p.text, .hinweis) { transition: opacity .6s var(--kurve); }
.welt:has(li.aktiv) .intro :is(p.text, .hinweis) { opacity: 0; pointer-events: none; }
/* Niedrige Fenster: die Ortskarte löst die Headline ab */
.intro h1 { transition: opacity .6s var(--kurve); }
@media (max-height: 820px) { .welt:has(li.aktiv) .intro h1 { opacity: 0; } .zahl strong { font-size: 3rem; } }
.intro h1 { margin-top: 10px; font-size: clamp(2.6rem, 1.6rem + 3.4vw, 4.75rem); line-height: .95; color: var(--marke); }
.intro p.text { margin-top: 16px; max-width: 34ch; font-size: 1.0625rem; color: var(--tinte); text-wrap: pretty; }
.hinweis { margin-top: 14px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: .875rem; color: #3d4a5c; }
.hinweis kbd { display: inline-grid; place-items: center; min-width: 24px; height: 24px; padding: 0 6px; border: 1px solid rgba(0, 46, 109, .3); border-bottom-width: 2px; border-radius: 4px; font: 600 .75rem/1 'Archivo', sans-serif; color: var(--marke); background: rgba(255, 255, 255, .7); }
.hinweis button { font: inherit; color: var(--marke); background: none; border: 0; padding: 3px 0; margin-block: -3px; text-decoration: underline; text-underline-offset: .25em; cursor: pointer; }

/* Orte: als Leiste (3D) oder als Raster (2D); dieselbe HTML-Liste ist die Abkürzung zu allen Inhalten */
.orte-kopf { position: absolute; z-index: 3; left: clamp(16px, 3vw, 40px); bottom: 84px; display: flex; align-items: baseline; gap: 12px; }
.orte-kopf .stand { font-size: .8125rem; color: #3d4a5c; }
/* Pause-Knopf (WCAG 2.2.2): hält Wasser, Bojen und Wellengang an; Steuern bleibt möglich */
.halt { align-self: center; display: inline-flex; align-items: center; gap: 6px; min-height: 24px; font: 500 .8125rem/1 'Archivo', sans-serif; color: var(--marke); background: none; border: 0; padding: 3px 0; cursor: pointer; text-decoration: underline; text-underline-offset: .25em; }
.halt svg { width: 9px; height: 9px; flex: none; }
.halt .spiel, .halt[aria-pressed='true'] .pause { display: none; }
.halt[aria-pressed='true'] .spiel { display: block; }
.vorschau .halt, .reduziert .halt { display: none; }
.orte { list-style: none; padding: 0; position: absolute; z-index: 3; left: clamp(16px, 3vw, 40px); bottom: 26px; display: flex; gap: 6px; max-width: calc(100% - 2 * clamp(16px, 3vw, 40px)); }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 14px 0 6px; border: 0; border-radius: 22px; background: var(--weiss); color: var(--marke); font: 600 .9375rem/1 'Archivo', sans-serif; cursor: pointer; white-space: nowrap; box-shadow: 0 1px 0 rgba(0, 46, 109, .12); transition: background-color var(--ui) linear, color var(--ui) linear; }
.chip .nr { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--marke); font: 800 1.05rem/1 'Big Shoulders', sans-serif; }
.chip:hover { background: #dbebf5; }
.orte li.gefunden .chip .nr { background: var(--marke); color: var(--weiss); }
.orte li.aktiv .chip { background: var(--marke); color: var(--weiss); }
.orte li.aktiv .chip .nr { border-color: var(--weiss); }
.karte-ort { position: absolute; left: 0; bottom: 98px; width: min(400px, calc(100vw - 32px)); padding: 22px 22px 20px; background: var(--weiss); border-radius: 6px; box-shadow: 0 18px 50px rgba(0, 46, 109, .18); opacity: 0; visibility: hidden; translate: 0 8px; transition: opacity .6s var(--kurve), translate .6s var(--kurve), visibility 0s linear .6s; }
.orte li.aktiv .karte-ort { opacity: 1; visibility: visible; translate: 0 0; transition: opacity .6s var(--kurve), translate .6s var(--kurve), visibility 0s; }
.karte-ort h2 { margin-top: 6px; font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem); line-height: 1; color: var(--marke); font-weight: 700; }
.karte-ort p.text { margin-top: 10px; font-size: .9375rem; line-height: 1.55; }
.zahl { margin-top: 14px; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.zahl strong { font-size: clamp(3rem, 2rem + 4vw, 4.25rem); line-height: .9; color: var(--marke); }
.zahl span { font-size: .875rem; font-weight: 500; max-width: 16ch; line-height: 1.3; }
.karte-ort .quelle { margin-top: 6px; font-size: .75rem; color: #5b6573; }
.karte-ort .knopf { margin-top: 16px; }

/* ---------- Pult ---------- */
.pult { position: absolute; z-index: 5; right: clamp(16px, 3vw, 40px); bottom: 26px; display: grid; justify-items: end; gap: 8px; padding-top: 18px; }
.pult .zeile { display: flex; gap: 6px; }
.schalter { display: inline-flex; padding: 3px; border-radius: 24px; background: rgba(255, 255, 255, .86); box-shadow: 0 1px 0 rgba(0, 46, 109, .12); }
.schalter button { font: 600 .8125rem/1 'Archivo', sans-serif; letter-spacing: .02em; color: var(--marke); background: none; border: 0; border-radius: 20px; padding: 11px 14px; cursor: pointer; transition: background-color var(--ui) linear, color var(--ui) linear; }
.schalter button[aria-pressed="true"] { background: var(--marke); color: var(--weiss); }
.werte { max-width: 470px; text-align: right; font-size: .75rem; line-height: 1.5; color: #3d4a5c; }
.werte b { color: var(--marke); font-weight: 600; }

/* ---------- 2D-Ansicht ---------- */
.zweid .leinwand, .zweid .intro .hinweis, .zweid .orte-kopf { display: none; }
.zweid .welt { height: auto; min-height: 0; padding: 110px clamp(16px, 3vw, 40px) 120px; background: var(--hell); }
.zweid .intro { position: static; max-width: 760px; }
.zweid .orte { position: static; margin-top: 40px; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; max-width: none; }
.zweid .orte li { background: var(--weiss); border-radius: 6px; padding: 16px; }
.zweid .chip { padding: 0; height: auto; background: none; box-shadow: none; pointer-events: none; }
.zweid .orte li.aktiv .chip { background: none; color: var(--marke); }
.zweid .karte-ort > .label, .zweid .intro p.text:not(.nur-2d), .nur-2d { display: none; }
.zweid .intro p.nur-2d { display: block; }
.nur-2d button { font: inherit; color: var(--marke); background: none; border: 0; padding: 0; text-decoration: underline; text-underline-offset: .25em; cursor: pointer; }
.zweid .karte-ort { position: static; opacity: 1; visibility: visible; translate: none; width: auto; padding: 8px 0 0; box-shadow: none; transition: none; }

.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger.an { display: block; }

/* ---------- Spenden-Streifen und Fuß ---------- */
.wirkung { background: var(--marke); color: var(--weiss); padding: clamp(56px, 8vw, 96px) clamp(16px, 3vw, 40px); }
.wirkung-innen { max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 40px; align-items: end; }
.wirkung h2 { font-size: clamp(1.75rem, 1.2rem + 2vw, 3rem); line-height: 1; font-weight: 700; }
.wirkung .label { color: #b8d8ea; }
.betraege { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.betraege li { border-top: 2px solid rgba(255, 255, 255, .35); padding-top: 12px; font-size: .9375rem; }
.betraege strong { display: block; font-size: 2.5rem; line-height: 1; }
.fuss { padding: 28px clamp(16px, 3vw, 40px) 36px; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 28px; font-size: .875rem; color: #3d4a5c; }

/* ---------- Vorschau: nur die Welt ---------- */
.vorschau body { overflow: hidden; }
.vorschau .kopf nav, .vorschau .intro p.text, .vorschau .hinweis, .vorschau .wirkung, .vorschau .fuss, .vorschau .orte-kopf .stand { display: none !important; }
.vorschau .intro h1 { font-size: 3.4rem; }

/* ---------- Telefon ---------- */
@media (max-width: 760px) {
  .kopf { gap: 12px; }
  .kopf nav { display: none; }
  .logo b { font-size: 1.35rem; }
  .intro { max-width: none; right: 16px; top: 74px; }
  .intro h1 { font-size: clamp(2.3rem, 11vw, 3rem); }
  .intro p.text { font-size: .9375rem; margin-top: 10px; }
  .hinweis .tasten { display: none; }
  .chip { padding: 0 4px; }
  .chip .nr { width: 34px; height: 34px; }
  .chip .name { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  .karte-ort { width: calc(100vw - 32px); bottom: 98px; padding: 18px; }
  .orte-kopf { bottom: 78px; }
  .halt { position: absolute; left: 0; bottom: calc(100% + 6px); white-space: nowrap; }
  .pult { left: auto; right: 16px; bottom: 26px; }
  .orte { gap: 4px; }
  .werte { display: none; }
  .wirkung-innen, .betraege { grid-template-columns: minmax(0, 1fr); }
  .zweid .orte { grid-template-columns: minmax(0, 1fr); }
}
@media (hover: none) { .leinwand canvas { cursor: default; } }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="welt" aria-labelledby="titel" data-welt>
  <div class="leinwand" data-leinwand tabindex="0" role="application" aria-roledescription="Spielwelt" aria-label="Kieler Förde als Spielwelt. Vereinsboot mit Pfeiltasten oder W, A, S, D steuern; Enter fährt zum nächsten Ort. Alle Orte stehen auch als Liste unten.">
    <canvas data-szene aria-hidden="true"></canvas>
  </div>
  <div class="ersatz" aria-hidden="true"></div>

  <header class="kopf">
    <a class="logo" href="#" aria-label="Fördegrün e.V. Startseite">
      <svg viewBox="0 0 30 30" aria-hidden="true"><circle cx="15" cy="15" r="14" fill="#002e6d"/><path d="M8 21c2.5-1.6 4.5-1.6 7 0s4.5 1.6 7 0" fill="none" stroke="#edf5fa" stroke-width="1.8" stroke-linecap="round"/><path d="M12 18c0-4 .5-7 2-10M16 18c0-3 1-6 3-8M14 18c-.6-2.6-1.8-4.4-3.4-5.6" fill="none" stroke="#b8d8ea" stroke-width="1.6" stroke-linecap="round"/></svg>
      <b>Fördegrün</b>
    </a>
    <nav aria-label="Hauptnavigation"><a href="#">Projekte</a><a href="#">Mitmachen</a><a href="#">Transparenz</a><a href="#">Über uns</a></nav>
    <a class="knopf spenden" href="#">Spenden</a>
  </header>

  <div class="intro">
    <p class="label">Mitmach-Karte · Kieler Förde</p>
    <h1 class="display" id="titel">Wir holen die Seegraswiesen zurück.</h1>
    <p class="text">Steuere unser Vereinsboot durch die Förde und finde fünf Orte, an denen Fördegrün e.V. arbeitet.</p>
    <p class="text nur-2d">Fünf Orte, an denen Fördegrün e.V. in der Kieler Förde arbeitet. Alles hier steht auch in der <button type="button" data-zur-karte>Spielkarte</button>.</p>
    <p class="hinweis"><span class="tasten"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> oder <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> · </span><span>Ins Wasser tippen fährt hin</span><button type="button" data-zur-liste>Lieber als Liste</button></p>
  </div>

  <p class="orte-kopf"><span class="label" data-fortschritt aria-live="polite">0 / 5 Orte entdeckt</span><span class="stand">Zahlen: Jahresbericht 2025</span><button type="button" class="halt" data-halt aria-pressed="false" aria-label="Bewegung anhalten"><svg class="pause" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg><svg class="spiel" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1l9 5-9 5z" fill="currentColor"/></svg><span class="halt-text">Bewegung anhalten</span></button></p>
  <ol class="orte" data-orte aria-label="Alle Orte der Karte">
    <li data-ort>
      <button class="chip" type="button" data-hin><span class="nr">1</span><span class="name">Schilksee</span></button>
      <article class="karte-ort" aria-label="Ort 1, Schilksee">
        <p class="label">Ort 1 · Schilksee</p>
        <h2 class="display">Strandputz</h2>
        <p class="text">Viermal im Jahr sammeln wir mit Schulen und Segelvereinen Müll vom Strand, bevor er ins Wasser geht.</p>
        <p class="zahl"><strong class="display" data-zahl="312">312</strong><span>Freiwillige beim Frühjahrsputz</span></p>
        <p class="quelle">Stand: Jahresbericht 2025</p>
        <a class="knopf" href="#">Beim nächsten Putz dabei sein</a>
      </article>
    </li>
    <li data-ort>
      <button class="chip" type="button" data-hin><span class="nr">2</span><span class="name">Friedrichsort</span></button>
      <article class="karte-ort" aria-label="Ort 2, Friedrichsort">
        <p class="label">Ort 2 · Friedrichsort</p>
        <h2 class="display">Messboje am Leuchtturm</h2>
        <p class="text">Die Boje misst alle 15 Minuten Temperatur, Sauerstoff und Trübung. Die Daten gehen offen an Schulen und die Uni.</p>
        <p class="zahl"><strong class="display" data-zahl="35040">35.040</strong><span>Messwerte im Jahr</span></p>
        <p class="quelle">Stand: Jahresbericht 2025</p>
        <a class="knopf" href="#">Messdaten ansehen</a>
      </article>
    </li>
    <li data-ort>
      <button class="chip" type="button" data-hin><span class="nr">3</span><span class="name">Möltenort</span></button>
      <article class="karte-ort" aria-label="Ort 3, Möltenort">
        <p class="label">Ort 3 · Möltenort</p>
        <h2 class="display">Jugend-Tauchgruppe</h2>
        <p class="text">Jugendliche ab 14 lernen tauchen und kartieren dabei die Seegraswiesen vor Heikendorf.</p>
        <p class="zahl"><strong class="display" data-zahl="126">126</strong><span>Jugendliche im Wasser</span></p>
        <p class="quelle">Stand: Jahresbericht 2025</p>
        <a class="knopf" href="#">Platz in der Gruppe anfragen</a>
      </article>
    </li>
    <li data-ort>
      <button class="chip" type="button" data-hin><span class="nr">4</span><span class="name">Kiellinie</span></button>
      <article class="karte-ort" aria-label="Ort 4, Kiellinie">
        <p class="label">Ort 4 · Kiellinie</p>
        <h2 class="display">Seegraswiese Düsternbrook</h2>
        <p class="text">Ehrenamtliche Taucher:innen setzen Seegras-Triebe in den Grund. Die Wiese bindet CO₂ und ist Kinderstube für Hering und Dorsch.</p>
        <p class="zahl"><strong class="display" data-zahl="41000">41.000</strong><span>Triebe gepflanzt seit 2021</span></p>
        <p class="quelle">Stand: Jahresbericht 2025</p>
        <a class="knopf" href="#">Pat:in für einen Quadratmeter werden</a>
      </article>
    </li>
    <li data-ort>
      <button class="chip" type="button" data-hin><span class="nr">5</span><span class="name">Hörn</span></button>
      <article class="karte-ort" aria-label="Ort 5, Hörn">
        <p class="label">Ort 5 · Hörn</p>
        <h2 class="display">Müllfalle im Hafen</h2>
        <p class="text">Ein schwimmender Korb fängt Plastik, bevor es aus dem Hafenbecken in die Ostsee treibt.</p>
        <p class="zahl"><strong class="display" data-zahl="1840">1.840</strong><span>kg Müll aus dem Hafenbecken</span></p>
        <p class="quelle">Stand: Jahresbericht 2025</p>
        <a class="knopf" href="#">Spendenaktion starten</a>
      </article>
    </li>
  </ol>

  <div class="pult">
    <p class="werte" data-werte aria-live="polite"></p>
    <div class="zeile">
      <div class="schalter" role="group" aria-label="Ansicht">
        <button type="button" data-ansicht="fahren" aria-pressed="true">Fahren</button>
        <button type="button" data-ansicht="karte" aria-pressed="false">Karte</button>
        <button type="button" data-ansicht="liste" aria-pressed="false">2D</button>
      </div>
      <div class="schalter"><button type="button" data-ton aria-pressed="false">Ton aus</button></div>
    </div>
  </div>
</section>

<section class="wirkung" aria-labelledby="wirkung-titel">
  <div class="wirkung-innen">
    <div>
      <p class="label">So wirkt deine Spende</p>
      <h2 class="display" id="wirkung-titel">Jeder Euro landet in der Förde.</h2>
      <p style="margin-top:20px"><a class="knopf spenden" href="#">Jetzt spenden</a></p>
    </div>
    <ul class="betraege">
      <li><strong class="display">30 €</strong>pflanzen 60 Seegras-Triebe</li>
      <li><strong class="display">60 €</strong>zahlen einen Tauchkurs für eine Jugendliche</li>
      <li><strong class="display">120 €</strong>halten die Messboje einen Monat in Betrieb</li>
    </ul>
  </div>
</section>
<footer class="fuss"><span>Fördegrün e.V. · Vereinsregister Kiel VR 6213 KI · gemeinnützig</span><span>Ansprechperson: Merle Sönnichsen · Satzung · Jahresbericht · Impressum</span></footer>

<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8" fill="rgba(0,46,109,.18)" stroke="#002e6d" stroke-width="2"/><circle cx="10" cy="10" r="2.5" fill="#002e6d"/></svg>

<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const welt = $('[data-welt]'), leinwand = $('[data-leinwand]'), canvas = $('[data-szene]'), zeiger = $('[data-zeiger]');
const schmal = () => innerWidth < 760;

/* ---------- Werte (Token) ---------- */
const W = {
  boot:   { beschleunigung: 7, max: 9, rueckwaerts: 3, widerstand: 1.1, drehen: 1.9, schraeglage: 0.05, abprall: 0.3 },
  kamera: { abstand: [0, 36, 34], fov: 36, lerp: 0.06, ueberblick: { pos: [-30, 112, 52], ziel: [-30, 0, -8], fov: 40 }, nebel: [95, 190], nebelUeberblick: [170, 320] },
  ort:    { radius: 6, verlassen: 10 },
  zahl:   { ms: 1400 },          // Odometer einmalig je Ort
  karte:  { ms: 600 },           // Ortskarte ein/aus, CSS
  ui: 150,
  kurve:  [0.645, 0.045, 0.355, 1],
  kielwasser: { takt: 90, leben: 1600, anzahl: 36 },
  stoss:  { staerke: 0.35, abklingen: 0.9 },
};
const kurve = (() => { const [x1, y1, x2, y2] = W.kurve; return p => { if (p <= 0) return 0; if (p >= 1) return 1; let t = p; for (let i = 0; i < 8; i++) { const x = 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t - p; const d = 3 * (1 - t) * (1 - t) * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2); if (Math.abs(d) < 1e-6) break; t -= x / d; } return 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t; }; })();
const zufall = (() => { let s = 7; return () => (s = (s * 16807) % 2147483647) / 2147483647; })();   // fester Startwert: gleiche Welt bei jedem Laden

/* ---------- Karte der Förde (x = Osten, z = Süden), Land als Ellipsen ---------- */
const LAND = [[-62, 5, 34, 95], [-30, -35, 12, 10], [64, 12, 34, 90], [30, -30, 10, 9], [0, 78, 46, 26]];
const ORTE = [[-30, -62], [-12, -36], [13, -28], [-21, 8], [0, 44]];
const RAND = { x: 0, z: -10, r: 92 };
// vor Möltenort, Bug nach Norden; im Hochformat näher an der Boje, damit sie unter dem Text im Bild liegt
const START = { x: 13, z: innerWidth / innerHeight < 0.9 ? -14 : -4, winkel: Math.PI };
const imLand = (x, z, plus = 0) => LAND.some(([cx, cz, rx, rz]) => ((x - cx) / (rx + plus)) ** 2 + ((z - cz) / (rz + plus)) ** 2 < 1);

/* ---------- Renderer ---------- */
let renderer = null;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
if (!renderer || !renderer.getContext()) { document.body.classList.add('ohne-webgl'); renderer = null; }
const software = (() => { if (!renderer) return false; const gl = renderer.getContext(), d = gl.getExtension('WEBGL_debug_renderer_info'); return /swiftshader|llvmpipe|software/i.test(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : ''); })();

const HELL = '#edf5fa';
const scene = new THREE.Scene();
scene.background = new THREE.Color(HELL);
scene.fog = new THREE.Fog(HELL, W.kamera.nebel[0], W.kamera.nebel[1]);
const camera = new THREE.PerspectiveCamera(W.kamera.fov, 1, 0.5, 400);
const zeitU = { value: 0 };
const flach = c => new THREE.MeshLambertMaterial({ color: c, flatShading: true });

scene.add(new THREE.HemisphereLight('#ffffff', '#9cb9cc', 1.6));
const sonne = new THREE.DirectionalLight('#fff6e8', 1.6); sonne.position.set(-30, 60, 25); scene.add(sonne);

// Wasser: flache Töne, Flachwasser und Schaum an der Küste, Seegraswiesen als dunkle Flecken
const srgb = h => new THREE.Vector3(...[1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255));
const wasser = new THREE.Mesh(new THREE.PlaneGeometry(320, 320, 1, 1), new THREE.ShaderMaterial({
  uniforms: { uZeit: zeitU, uLand: { value: LAND.map(l => new THREE.Vector4(...l)) }, uKamera: { value: new THREE.Vector3() }, uNebel: { value: srgb(HELL) }, uNebelAb: { value: new THREE.Vector2(...W.kamera.nebel) },
    uGras: { value: [new THREE.Vector3(-23, 14, 7), new THREE.Vector3(-25, 0, 5), new THREE.Vector3(12, -22, 5)] } },
  vertexShader: `varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`,
  fragmentShader: `uniform float uZeit; uniform vec4 uLand[5]; uniform vec3 uKamera, uNebel; uniform vec2 uNebelAb; uniform vec3 uGras[3]; varying vec3 vW;
    float h(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
    float n(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + vec2(1, 1)), f.x), f.y); }
    void main(){
      vec2 p = vW.xz; float d = 1e5;
      for (int i = 0; i < 5; i++) { vec2 q = (p - uLand[i].xy) / uLand[i].zw; d = min(d, (length(q) - 1.0) * min(uLand[i].z, uLand[i].w)); }
      vec3 c = mix(vec3(0.72, 0.847, 0.918), vec3(0.47, 0.70, 0.85), smoothstep(0.5, 10.0, d));
      for (int i = 0; i < 3; i++) { float g = smoothstep(uGras[i].z, uGras[i].z * 0.35, length(p - uGras[i].xy) + (n(p * 0.7) - 0.5) * 3.0); c = mix(c, vec3(0.36, 0.60, 0.63), g * 0.55); }
      float w = sin(p.x * 0.3 + p.y * 0.5 + uZeit * 0.8 + n(p * 0.12) * 6.0);
      c += vec3(0.05) * smoothstep(0.94, 1.0, w) * smoothstep(1.5, 6.0, d);
      float schaum = (1.0 - smoothstep(0.2, 1.3, d)) * (0.75 + 0.25 * sin(d * 4.0 - uZeit * 2.2));
      c = mix(c, vec3(1.0), clamp(schaum, 0.0, 1.0) * 0.9);
      c = mix(c, uNebel, smoothstep(uNebelAb.x, uNebelAb.y, length(vW - uKamera)));
      gl_FragColor = vec4(c, 1.0);
    }`,
}));
wasser.rotation.x = -Math.PI / 2; wasser.position.set(0, 0, -10); scene.add(wasser);

// Land: flache Plateaus mit schrägem Rand, warmes Grau wie Papier
const landMat = flach('#dfdfd7');
LAND.forEach(([x, z, rx, rz]) => {
  const m = new THREE.Mesh(new THREE.CylinderGeometry(0.97, 1, 1.6, 56), landMat);
  m.scale.set(rx, 1, rz); m.position.set(x, 0.5, z); scene.add(m);
});

// Bäume und Häuser als InstancedMesh, nur in Küstennähe
const m4 = new THREE.Matrix4(), q4 = new THREE.Quaternion(), e4 = new THREE.Euler(), s4 = new THREE.Vector3(), p4 = new THREE.Vector3(), farbe = new THREE.Color();
// Punkt an Land, „innen“ bis „aussen“ Einheiten hinter der Uferlinie, nur im Bereich der Förde
function punktAmUfer(innen, aussen) {
  for (let v = 0; v < 80; v++) {
    const l = LAND[[0, 0, 0, 2, 2, 2, 4, 4, 1, 3][Math.floor(zufall() * 10)]];
    const a = zufall() * Math.PI * 2, d = innen + zufall() * (aussen - innen);
    const x = l[0] + Math.cos(a) * Math.max(0, l[2] - d), z = l[1] + Math.sin(a) * Math.max(0, l[3] - d);
    if (Math.abs(x) < 48 && z > -90 && z < 62 && imLand(x, z, -1.5)) return [x, z];
  }
  return null;
}
const BAEUME = 150, baeume = new THREE.InstancedMesh(new THREE.ConeGeometry(1, 2.6, 6), flach('#ffffff'), BAEUME);
let gruppe = null;
for (let i = 0; i < BAEUME; i++) {
  if (i % 6 === 0) gruppe = punktAmUfer(4, 16);                       // Wäldchen aus je sechs Bäumen
  const p = gruppe ? [gruppe[0] + (zufall() - 0.5) * 6, gruppe[1] + (zufall() - 0.5) * 6] : [-80, -80], s = 0.7 + zufall() * 0.6;
  if (!imLand(p[0], p[1], -1.5)) { p[0] = -200; }
  m4.compose(p4.set(p[0], 1.3 + 1.3 * s, p[1]), q4.setFromEuler(e4.set(0, zufall() * 3, 0)), s4.set(s, s, s));
  baeume.setMatrixAt(i, m4); baeume.setColorAt(i, farbe.set(['#6f9a86', '#5f8b78', '#86ab97'][i % 3]));
}
scene.add(baeume);
const HAEUSER = 34, haus = new THREE.InstancedMesh(new THREE.BoxGeometry(1.8, 1.3, 1.6), flach('#ffffff'), HAEUSER);
const dachForm = new THREE.CylinderGeometry(1.15, 1.15, 1.9, 3); dachForm.rotateY(Math.PI / 2); dachForm.rotateZ(Math.PI / 2);   // Prisma liegt, First oben
const dach = new THREE.InstancedMesh(dachForm, flach('#ffffff'), HAEUSER);
for (let i = 0; i < HAEUSER; i++) {
  const p = punktAmUfer(2.5, 7) || [-80, -80], r = zufall() * Math.PI;
  q4.setFromEuler(e4.set(0, r, 0));
  m4.compose(p4.set(p[0], 1.3 + 0.65, p[1]), q4, s4.set(1, 1, 1)); haus.setMatrixAt(i, m4);
  haus.setColorAt(i, farbe.set(i % 4 === 0 ? '#c9d9e4' : '#ffffff'));
  m4.compose(p4.set(p[0], 2.6 + 0.575 * 0.6, p[1]), q4, s4.set(1, 0.6, 0.8)); dach.setMatrixAt(i, m4);
  dach.setColorAt(i, farbe.set(i % 3 === 0 ? '#5b6573' : '#002e6d'));
}
scene.add(haus, dach);

// Leuchtturm Friedrichsort
const turm = new THREE.Group();
turm.add(new THREE.Mesh(new THREE.CylinderGeometry(0.75, 1.05, 6, 12), flach('#ffffff')));
[-1.4, 0.6].forEach(y => { const b = new THREE.Mesh(new THREE.CylinderGeometry(0.86, 0.92, 0.9, 12), flach('#002e6d')); b.position.y = y; turm.add(b); });
const laterne = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.55, 0.8, 8), new THREE.MeshBasicMaterial({ color: '#ffffff' })); laterne.position.y = 3.4; turm.add(laterne);
const turmdach = new THREE.Mesh(new THREE.ConeGeometry(0.8, 0.8, 8), flach('#002e6d')); turmdach.position.y = 4.2; turm.add(turmdach);
turm.position.set(-21.5, 1.3 + 3, -36.5); scene.add(turm);

/* ---------- Bojen an den fünf Orten ---------- */
const etikettZeichnen = (nr, gefunden) => {
  const c = document.createElement('canvas'); c.width = c.height = 128;
  const x = c.getContext('2d');
  x.fillStyle = gefunden ? '#002e6d' : '#ffffff'; x.beginPath(); x.arc(64, 64, 58, 0, Math.PI * 2); x.fill();
  x.lineWidth = 8; x.strokeStyle = '#002e6d'; x.stroke();
  x.fillStyle = gefunden ? '#ffffff' : '#002e6d'; x.textAlign = 'center'; x.textBaseline = 'middle';
  if (gefunden) { x.lineWidth = 12; x.strokeStyle = '#ffffff'; x.lineCap = 'round'; x.lineJoin = 'round'; x.beginPath(); x.moveTo(38, 66); x.lineTo(56, 84); x.lineTo(90, 46); x.stroke(); }
  else { x.font = '800 76px "Big Shoulders", sans-serif'; x.fillText(String(nr), 64, 70); }
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
};
const bojen = ORTE.map(([x, z], i) => {
  const g = new THREE.Group();
  const unten = new THREE.Mesh(new THREE.CylinderGeometry(0.75, 0.95, 1.1, 10), flach('#002e6d')); unten.position.y = 0.2;
  const band = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.72, 0.5, 10), flach('#ffffff')); band.position.y = 1.0;
  const spitze = new THREE.Mesh(new THREE.ConeGeometry(0.62, 0.9, 10), flach('#002e6d')); spitze.position.y = 1.7;
  const ring = new THREE.Mesh(new THREE.RingGeometry(W.ort.radius - 0.35, W.ort.radius, 48), new THREE.MeshBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.55, depthWrite: false }));
  ring.rotation.x = -Math.PI / 2; ring.position.y = 0.04;
  const schild = new THREE.Sprite(new THREE.SpriteMaterial({ map: etikettZeichnen(i + 1, false), depthTest: false }));
  schild.position.y = 3.6; schild.scale.set(2.6, 2.6, 1); schild.renderOrder = 5;
  g.add(unten, band, spitze, ring, schild); g.position.set(x, 0, z); scene.add(g);
  return { g, ring, schild, x, z, gefunden: false };
});

/* ---------- Vereinsboot ---------- */
const boot = new THREE.Group(), rumpf = new THREE.Group();
{
  const umriss = new THREE.Shape();
  umriss.moveTo(-0.95, -1.7); umriss.lineTo(0.95, -1.7); umriss.lineTo(1.02, 0.4); umriss.quadraticCurveTo(0.95, 1.6, 0, 2.4); umriss.quadraticCurveTo(-0.95, 1.6, -1.02, 0.4); umriss.closePath();
  const hull = new THREE.ExtrudeGeometry(umriss, { depth: 0.75, bevelEnabled: true, bevelThickness: 0.12, bevelSize: 0.12, bevelSegments: 1, curveSegments: 6 });
  hull.rotateX(Math.PI / 2); hull.translate(0, 0.62, 0);
  const deckForm = new THREE.ExtrudeGeometry(umriss, { depth: 0.06, bevelEnabled: false, curveSegments: 6 });
  deckForm.rotateX(Math.PI / 2); deckForm.scale(0.86, 1, 0.86); deckForm.translate(0, 0.72, 0);
  rumpf.add(new THREE.Mesh(hull, flach('#002e6d')), new THREE.Mesh(deckForm, flach('#ffffff')));
  const kabine = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.75, 1.05), flach('#ffffff')); kabine.position.set(0, 1.08, -0.35);
  const fenster = new THREE.Mesh(new THREE.BoxGeometry(1.17, 0.2, 1.07), flach('#2b4a6e')); fenster.position.set(0, 1.18, -0.35);
  const kdach = new THREE.Mesh(new THREE.BoxGeometry(1.3, 0.1, 1.2), flach('#002e6d')); kdach.position.set(0, 1.5, -0.35);
  const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.5, 6), flach('#002e6d')); mast.position.set(0, 2.2, -0.6);
  const flagge = new THREE.Mesh(new THREE.PlaneGeometry(0.6, 0.38), new THREE.MeshLambertMaterial({ color: '#b8d8ea', side: THREE.DoubleSide })); flagge.position.set(0, 2.72, -0.92); flagge.rotation.y = Math.PI / 2;
  rumpf.add(kabine, fenster, kdach, mast, flagge);
  const schatten = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), new THREE.MeshBasicMaterial({ color: '#002e6d', transparent: true, opacity: 0.12, depthWrite: false }));
  schatten.rotation.x = -Math.PI / 2; schatten.scale.set(0.9, 1.5, 1); schatten.position.y = 0.03;
  boot.add(schatten, rumpf); rumpf.position.y = -0.3;
}
scene.add(boot);

// Kielwasser: weiße Scheiben, die hinter dem Heck wachsen und vergehen
const kiel = new THREE.InstancedMesh(new THREE.CircleGeometry(1, 18), new THREE.MeshBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.6, depthWrite: false }), W.kielwasser.anzahl);
const kielDaten = Array.from({ length: W.kielwasser.anzahl }, () => ({ x: 0, z: 0, t: -1e9 }));
let kielIndex = 0, kielUhr = 0;
kiel.frustumCulled = false; scene.add(kiel);

/* ---------- Zustand ---------- */
const b = { x: START.x, z: START.z, winkel: START.winkel, v: 0, kraengung: 0 };
const tasten = new Set();
let ziel = null, ansicht = 'fahren', stoss = 0, aktiv = -1, gefunden = 0;
const kam = { pos: new THREE.Vector3(), blick: new THREE.Vector3(), fov: W.kamera.fov };

/* ---------- Eingabe ---------- */
const TASTEN = { ArrowUp: 'vor', KeyW: 'vor', ArrowDown: 'zurueck', KeyS: 'zurueck', ArrowLeft: 'links', KeyA: 'links', ArrowRight: 'rechts', KeyD: 'rechts' };
leinwand.addEventListener('keydown', e => {
  const t = TASTEN[e.code];
  if (t) { e.preventDefault(); tasten.add(t); ziel = null; stoppeVorfuehrung(); starten(); }
  if (e.key === 'Enter') { e.preventDefault(); stoppeVorfuehrung(); fahreZu((Math.max(aktiv, naechsterOrt()) + (aktiv >= 0 ? 1 : 0)) % ORTE.length); }
});
leinwand.addEventListener('keyup', e => { const t = TASTEN[e.code]; if (t) tasten.delete(t); });
leinwand.addEventListener('blur', () => tasten.clear());
// Klick oder Tippen ins Wasser: Boot fährt dorthin (Tap-to-move)
const strahl = new THREE.Raycaster(), ebene = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), nd = new THREE.Vector2(), treffer = new THREE.Vector3();
let druck = null;
leinwand.addEventListener('pointerdown', e => { druck = { x: e.clientX, y: e.clientY }; });
leinwand.addEventListener('pointerup', e => {
  if (!druck || Math.hypot(e.clientX - druck.x, e.clientY - druck.y) > 8 || !renderer) return;
  const r = canvas.getBoundingClientRect();
  nd.set((e.clientX - r.left) / r.width * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
  strahl.setFromCamera(nd, camera);
  if (!strahl.ray.intersectPlane(ebene, treffer)) return;
  stoppeVorfuehrung(); leinwand.focus({ preventScroll: true });
  const o = ORTE.findIndex(([x, z]) => Math.hypot(x - treffer.x, z - treffer.z) < 5);
  if (o >= 0) fahreZu(o); else if (!imLand(treffer.x, treffer.z, 1.2)) ziel = { x: treffer.x, z: treffer.z, ort: -1 };
  starten();
});
function naechsterOrt() { let m = 0, d = 1e9; ORTE.forEach(([x, z], i) => { const a = Math.hypot(x - b.x, z - b.z); if (a < d) { d = a; m = i; } }); return m; }
function fahreZu(i) {
  const [x, z] = ORTE[i];
  if (reduziert() || !renderer) { b.x = x + 3; b.z = z + 3; b.v = 0; pruefeOrte(true); zeichnen(); return; }
  ziel = { x, z, ort: i }; starten();
}
$$('[data-hin]').forEach((k, i) => k.addEventListener('click', () => { stoppeVorfuehrung(); if (ansicht === 'liste') return; fahreZu(i); }));

/* ---------- Orte und Odometer ---------- */
const orteLi = $$('[data-ort]');
const zahlFormat = new Intl.NumberFormat('de-DE');
function zaehle(el) {
  if (el.dataset.gezaehlt) return; el.dataset.gezaehlt = '1';
  const ende = +el.dataset.zahl;
  if (reduziert()) { el.textContent = zahlFormat.format(ende); return; }
  const t0 = performance.now();
  const lauf = t => { const p = Math.min(1, (t - t0) / W.zahl.ms); el.textContent = zahlFormat.format(Math.round(ende * kurve(p))); if (p < 1) requestAnimationFrame(lauf); };
  el.textContent = '0'; requestAnimationFrame(lauf);
}
function setzeAktiv(i) {
  if (i === aktiv) return;
  aktiv = i;
  orteLi.forEach((li, j) => li.classList.toggle('aktiv', j === i));
  if (i < 0) return;
  const bo = bojen[i];
  if (!bo.gefunden) {
    bo.gefunden = true; gefunden++;
    orteLi[i].classList.add('gefunden');
    bo.schild.material.map = etikettZeichnen(i + 1, true); bo.schild.material.needsUpdate = true;
    bo.ring.material.opacity = 0.25;
    $('[data-fortschritt]').textContent = gefunden === ORTE.length ? 'Alle 5 Orte entdeckt. Danke fürs Mitfahren!' : `${gefunden} / 5 Orte entdeckt`;
    ton.glocke();
  }
  zaehle(orteLi[i].querySelector('[data-zahl]'));
}
function pruefeOrte(sofort) {
  let nah = -1;
  ORTE.forEach(([x, z], i) => { const d = Math.hypot(x - b.x, z - b.z); if (d < W.ort.radius + (sofort ? 4 : 0)) nah = i; });
  if (nah >= 0) setzeAktiv(nah);
  else if (aktiv >= 0) { const [x, z] = ORTE[aktiv]; if (Math.hypot(x - b.x, z - b.z) > W.ort.verlassen) setzeAktiv(-1); }
}

/* ---------- Ton: nur nach Klick, standardmäßig aus ---------- */
const ton = (() => {
  let ctx = null, an = false, rauschen = null, laut = null;
  function starte() {
    ctx = ctx || new (window.AudioContext || window.webkitAudioContext)();
    const buf = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate), d = buf.getChannelData(0);
    let l = 0; for (let i = 0; i < d.length; i++) { l = (l + 0.02 * (Math.random() * 2 - 1)) / 1.02; d[i] = l * 3.5; }   // braunes Rauschen = Wellen
    rauschen = ctx.createBufferSource(); rauschen.buffer = buf; rauschen.loop = true;
    const filter = ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 520;
    laut = ctx.createGain(); laut.gain.value = 0.05;
    const lfo = ctx.createOscillator(), lfoTiefe = ctx.createGain(); lfo.frequency.value = 0.12; lfoTiefe.gain.value = 0.025; lfo.connect(lfoTiefe).connect(laut.gain); lfo.start();
    rauschen.connect(filter).connect(laut).connect(ctx.destination); rauschen.start();
  }
  function stoppe() { if (rauschen) { rauschen.stop(); rauschen = null; } }
  function klang(frequenzen, dauer, staerke) {
    if (!an || !ctx) return;
    const t = ctx.currentTime;
    frequenzen.forEach((f, i) => { const o = ctx.createOscillator(), g = ctx.createGain(); o.type = 'sine'; o.frequency.value = f; g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(staerke / (i + 1), t + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, t + dauer); o.connect(g).connect(ctx.destination); o.start(t); o.stop(t + dauer); });
  }
  return {
    schalte() { an = !an; if (an) starte(); else stoppe(); return an; },
    glocke() { klang([784, 1176, 1568], 1.4, 0.12); },
    plock() { klang([180, 120], 0.25, 0.18); },
  };
})();
$('[data-ton]').addEventListener('click', e => { const an = ton.schalte(); e.currentTarget.setAttribute('aria-pressed', an); e.currentTarget.textContent = an ? 'Ton an' : 'Ton aus'; });

/* ---------- Physik ---------- */
function physik(dt) {
  let gas = 0, lenk = 0;
  if (tasten.has('vor')) gas += 1;
  if (tasten.has('zurueck')) gas -= 1;
  if (tasten.has('links')) lenk += 1;
  if (tasten.has('rechts')) lenk -= 1;
  if (ziel) {
    const dx = ziel.x - b.x, dz = ziel.z - b.z, d = Math.hypot(dx, dz);
    let diff = Math.atan2(dx, dz) - b.winkel; diff = Math.atan2(Math.sin(diff), Math.cos(diff));
    lenk = Math.max(-1, Math.min(1, diff * 2.2));
    gas = d > (ziel.ort >= 0 ? 3.2 : 1.5) ? (Math.abs(diff) > 1.4 ? 0.35 : 1) : 0;
    if (gas === 0 && Math.abs(b.v) < 0.6) ziel = null;
    if (ziel && d < 3.2) b.v *= 0.94;
  }
  const max = gas >= 0 ? W.boot.max : W.boot.rueckwaerts;
  b.v += (gas * W.boot.beschleunigung - W.boot.widerstand * b.v) * dt;
  b.v = Math.max(-max, Math.min(max, b.v));
  const lenkKraft = Math.min(1, 0.35 + Math.abs(b.v) / 4) * Math.sign(b.v || 1);
  b.winkel += lenk * W.boot.drehen * lenkKraft * dt;
  b.kraengung += ((-lenk * Math.abs(b.v) * W.boot.schraeglage) - b.kraengung) * Math.min(1, dt * 4);
  let nx = b.x + Math.sin(b.winkel) * b.v * dt, nz = b.z + Math.cos(b.winkel) * b.v * dt;
  // Kollision mit dem Ufer: aus der Ellipse schieben, abprallen
  for (const [cx, cz, rx, rz] of LAND) {
    const ax = rx + 1.3, az = rz + 1.3, e = ((nx - cx) / ax) ** 2 + ((nz - cz) / az) ** 2;
    if (e < 1) { const k = 1 / Math.sqrt(e); nx = cx + (nx - cx) * k; nz = cz + (nz - cz) * k; if (Math.abs(b.v) > 2) { stoss = 1; ton.plock(); } b.v *= -W.boot.abprall; ziel = null; }
  }
  const rd = Math.hypot(nx - RAND.x, nz - RAND.z);
  if (rd > RAND.r) { nx = RAND.x + (nx - RAND.x) / rd * RAND.r; nz = RAND.z + (nz - RAND.z) / rd * RAND.r; b.v *= 0.5; }
  b.x = nx; b.z = nz;
}

/* ---------- Kamera ---------- */
const blickZiel = new THREE.Vector3(), posZiel = new THREE.Vector3();
function kameraZiel() {
  const ue = ansicht === 'karte' || reduziert(), nb = ue ? W.kamera.nebelUeberblick : W.kamera.nebel;
  scene.fog.near = nb[0]; scene.fog.far = nb[1]; wasser.material.uniforms.uNebelAb.value.set(nb[0], nb[1]);
  if (ue) {
    const u = W.kamera.ueberblick, f = camera.aspect < 0.9 ? 1.6 : camera.aspect < 1.3 ? 1.15 : 1, x = camera.aspect > 1.2 && !schmal() ? u.pos[0] : 0;
    posZiel.set(x, u.pos[1] * f, u.pos[2] * f); blickZiel.set(x, u.ziel[1], u.ziel[2]); return u.fov;
  }
  const a = W.kamera.abstand, f = camera.aspect < 0.9 ? 1.35 : 1;
  // Boot sitzt etwas rechts der Mitte (Desktop) und im Hochformat im unteren Drittel, damit der Text frei bleibt
  const seit = camera.aspect > 1.2 && !schmal() ? -12 : 0;
  posZiel.set(b.x + a[0] + seit, a[1] * f, b.z + a[2] * f); blickZiel.set(b.x + seit, 0, b.z - (camera.aspect < 0.9 ? 17 : 2));
  return W.kamera.fov;
}
function kameraSetzen(dt, sofort) {
  const fov = kameraZiel();
  const k = sofort || reduziert() ? 1 : 1 - Math.pow(1 - W.kamera.lerp, dt * 60);
  kam.pos.lerp(posZiel, k); kam.blick.lerp(blickZiel, k); kam.fov += (fov - kam.fov) * k;
  camera.position.copy(kam.pos);
  if (stoss > 0.01 && !reduziert()) { camera.position.x += (Math.random() - 0.5) * W.stoss.staerke * stoss; camera.position.y += (Math.random() - 0.5) * W.stoss.staerke * stoss; }
  camera.fov = kam.fov; camera.updateProjectionMatrix();
  camera.lookAt(kam.blick);
}

/* ---------- Bildschleife ---------- */
let laeuft = false, angehalten = false, letzte = performance.now(), zeit = 0, mittel = 16;
// Pause-Knopf: friert die Umgebungsbewegung ein wie bei reduzierter Bewegung, Steuern bleibt möglich
let nutzerHalt = false;
const haltKnopf = $('[data-halt]');
haltKnopf.addEventListener('click', () => { nutzerHalt = !nutzerHalt; haltKnopf.setAttribute('aria-pressed', String(nutzerHalt)); });
function zeichnen() {
  if (!renderer) return;
  boot.position.set(b.x, 0, b.z); boot.rotation.y = b.winkel;
  rumpf.rotation.z = reduziert() ? 0 : b.kraengung;
  wasser.material.uniforms.uKamera.value.copy(camera.position);
  renderer.render(scene, camera);
}
function bild(t) {
  if (angehalten || ansicht === 'liste') { laeuft = false; return; }
  const dt = Math.min(0.05, (t - letzte) / 1000); letzte = t;
  const still = reduziert() || nutzerHalt;
  if (!still) zeit += dt;
  zeitU.value = zeit;
  physik(dt);
  pruefeOrte();
  if (!still) {
    rumpf.position.y = -0.3 + Math.sin(zeit * 2.1) * 0.05; rumpf.rotation.x = Math.sin(zeit * 1.7) * 0.025 - b.v * 0.004;
    bojen.forEach((bo, i) => { bo.g.position.y = Math.sin(zeit * 1.6 + i * 1.3) * 0.12; bo.g.rotation.z = Math.sin(zeit * 1.2 + i) * 0.05; bo.ring.scale.setScalar(1 + Math.sin(zeit * 2 + i) * 0.03); });
    // Kielwasser
    kielUhr += dt * 1000;
    if (Math.abs(b.v) > 1.2 && kielUhr > W.kielwasser.takt) {
      kielUhr = 0; const k = kielDaten[kielIndex++ % W.kielwasser.anzahl];
      k.x = b.x - Math.sin(b.winkel) * 1.9; k.z = b.z - Math.cos(b.winkel) * 1.9; k.t = zeit;
    }
    kielDaten.forEach((k, i) => {
      const a = (zeit - k.t) / (W.kielwasser.leben / 1000), s = a >= 0 && a < 1 ? (0.5 + 1.8 * a) * (1 - a * a) : 0;
      m4.compose(p4.set(k.x, 0.03, k.z), q4.setFromEuler(e4.set(-Math.PI / 2, 0, 0)), s4.set(s, s * 0.7, 1)); kiel.setMatrixAt(i, m4);
    });
    kiel.instanceMatrix.needsUpdate = true;
    stoss *= Math.pow(W.stoss.abklingen, dt * 60);
  }
  kameraSetzen(dt);
  zeichnen();
  mittel += (dt * 1000 - mittel) * 0.1;
  if (mittel > 45) setTimeout(() => requestAnimationFrame(bild), 40); else requestAnimationFrame(bild);
}
function starten() { if (renderer && !laeuft && !angehalten && ansicht !== 'liste') { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }

/* ---------- Ansichten ---------- */
function zeigeWerte() {
  const z = {
    fahren: `Boot <b>${W.boot.max} m/s</b> · Drehen <b>${W.boot.drehen} rad/s</b> · Kamera folgt <b>Lerp ${W.kamera.lerp}</b>, fov <b>${W.kamera.fov}</b>`,
    karte: `Überblick fov <b>${W.kamera.ueberblick.fov}</b> · Orte anklicken · Kamera ohne Schwenk`,
    liste: `2D-Abkürzung: alle Inhalte als HTML-Liste, <b>kein WebGL</b>`,
  }[ansicht];
  $('[data-werte]').innerHTML = `${z}<br>Karte <b>${W.karte.ms} ms</b> · Zahl <b>${W.zahl.ms} ms</b> · <b>cubic-bezier(${W.kurve.join(', ')})</b>`;
}
function setzeAnsicht(a) {
  ansicht = a;
  $$('[data-ansicht]').forEach(k => k.setAttribute('aria-pressed', k.dataset.ansicht === a));
  html.classList.toggle('zweid', a === 'liste');
  if (a === 'liste') { $$('[data-zahl]').forEach(el => { el.dataset.gezaehlt = '1'; el.textContent = zahlFormat.format(+el.dataset.zahl); }); }
  else { groesse(); starten(); }
  zeigeWerte();
}
$$('[data-ansicht]').forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeAnsicht(k.dataset.ansicht); }));
$('[data-zur-karte]').addEventListener('click', () => { stoppeVorfuehrung(); setzeAnsicht('fahren'); leinwand.focus({ preventScroll: true }); });
$('[data-zur-liste]').addEventListener('click', () => { stoppeVorfuehrung(); setzeAnsicht('liste'); $('[data-orte]').scrollIntoView({ block: 'start' }); });

function groesse() {
  if (!renderer) return;
  const w = welt.clientWidth, h = welt.clientHeight;
  renderer.setPixelRatio(software ? 1 : Math.min(devicePixelRatio || 1, 2));
  renderer.setSize(w, h, false);
  camera.aspect = w / h; camera.updateProjectionMatrix();
  kameraSetzen(0, true); zeichnen();
}

/* ---------- Vorführung: Autopilot fährt die Orte ab ---------- */
let vorf = null, route = [2, 1, 0, 3, 4], routeI = 0, warteBis = 0;
function vorfuehren() {
  stoppeVorfuehrung(true);
  angehalten = false;
  html.classList.add('vorfuehrung');
  if (ansicht === 'liste') setzeAnsicht('fahren');
  if (reduziert() || !renderer) {   // ohne Bewegung: Boot steht nacheinander an den Orten
    let i = 0; fahreZu(route[0]);
    vorf = { intervall: setInterval(() => { i = (i + 1) % route.length; fahreZu(route[i]); }, 4000) };
    return;
  }
  // Start angelegt am ersten Ort: Karte ist sofort zu sehen, nach 3,2 s legt das Boot ab
  routeI = 0; ziel = null; tasten.clear();
  Object.assign(b, { x: ORTE[route[0]][0] + 1.5, z: ORTE[route[0]][1] + 3.5, winkel: Math.PI * 0.85, v: 0 });
  pruefeOrte(true); kameraSetzen(0, true);
  warteBis = performance.now() + 3200;
  const schritt = t => {
    if (!ziel && !warteBis) warteBis = t + 1800;             // am Ort kurz halten, Karte lesen
    if (warteBis && t > warteBis) { warteBis = 0; routeI = (routeI + 1) % route.length; fahreZu(route[routeI]); }
    // gedachter Zeiger: zeigt auf den nächsten Ort
    const zo = ziel && ziel.ort >= 0 ? ORTE[ziel.ort] : null;
    if (zo) {
      p4.set(zo[0], 0, zo[1]).project(camera);
      const r = canvas.getBoundingClientRect();
      zeiger.style.transform = `translate(${r.left + (p4.x + 1) / 2 * r.width - 10}px, ${r.top + (1 - p4.y) / 2 * r.height - 10}px)`;
      zeiger.classList.add('an');
    } else zeiger.classList.remove('an');
    vorf = requestAnimationFrame(schritt);
  };
  vorf = requestAnimationFrame(schritt);
  starten();
}
function stoppeVorfuehrung(still) {
  if (vorf && vorf.intervall) clearInterval(vorf.intervall); else if (vorf) cancelAnimationFrame(vorf);
  vorf = null; zeiger.classList.remove('an');
  if (!still) html.classList.remove('vorfuehrung');
}
function pause() { if (vorf && vorf.intervall) clearInterval(vorf.intervall); else if (vorf) cancelAnimationFrame(vorf); vorf = null; angehalten = true; }
function weiter() { angehalten = false; if (html.classList.contains('vorfuehrung')) vorfuehren(); else starten(); }
function neustart() {
  stoppeVorfuehrung(); angehalten = false;
  Object.assign(b, { x: START.x, z: START.z, winkel: START.winkel, v: 0, kraengung: 0 }); ziel = null; tasten.clear();
  setzeAktiv(-1);
  if (ansicht === 'liste') setzeAnsicht('fahren');
  kameraSetzen(0, true); zeichnen(); starten();
}


/* ---------- Start ---------- */
zeigeWerte();
addEventListener('resize', groesse);
if (renderer) {
  groesse(); starten();
  document.fonts.load('800 76px "Big Shoulders"').then(() => bojen.forEach((bo, i) => { bo.schild.material.map = etikettZeichnen(i + 1, bo.gefunden); bo.schild.material.needsUpdate = true; })).catch(() => {});
} else setzeAnsicht('liste');
</script>
</body>
</html>