Zum Inhalt
← Schrift-Animationen

Buchstaben-Physik

Zwölf Lernziele fallen als Pillen in einen Kasten, stapeln sich und lassen sich greifen und werfen; dazu Buchstaben mit Schwerkraft und federnde Buchstaben, die dem Zeiger ausweichen.

  1. Wörter oder Buchstaben werden zu Physik-Körpern, in einer 2D-Engine wie Matter.js oder in einer eigenen Simulation.
  2. Beim Eintritt in den sichtbaren Bereich fallen sie mit Schwerkraft in einen Container, prallen ab und stapeln sich.
  3. Nutzer greifen sie mit Maus oder Finger, werfen oder schubsen sie.
  4. Leichtere Variante: Buchstaben weichen dem Zeiger federnd aus und schwingen zurück.

Spielerisch und überraschend. Text, der sich anfassen lässt, lädt zum Ausprobieren ein und bleibt im Gedächtnis. Gut für Easter Eggs im Footer oder für einen Moment der Belohnung.

Wann einsetzen

Footer, 404-Seiten, Kampagnen, verspielte Marken, Tag- und Skill-Wolken, Abschluss- und Erfolgsmomente.

Wann nicht

Für Inhalte, die gelesen werden müssen. In ernsten oder seriösen Zusammenhängen. Auf schwachen Geräten mit vielen Körpern.

Varianten

  • Fallende Wörter mit Schwerkraft (Osmo „Falling Text with Gravity“)
  • Greifen und Werfen, in Matter.js mit MouseConstraint
  • Federndes Ausweichen vor dem Zeiger
  • Halten zum Sprengen: Buchstaben fliegen nach längerem Drücken auseinander
  • Wackelnde Buchstaben wie Wackelpudding (Pete Barr „Jello“ in der GSAP-Text-Galerie)

Umsetzung

Buchstaben und Wörter als DOM-Elemente behalten (Barrierefreiheit, Schärfe) und die Engine nur für Positionen nutzen. Mit Matter.js: pro Element Bodies.rectangle(x, y, w, h, { restitution: 0.4, friction: 0.1 }), im afterUpdate transform: translate(x, y) rotate(angle) setzen. Ohne Bibliothek reicht eine Verlet-Simulation mit Kapseln: zwei Punkte pro Körper, ein Stab fester Länge, Kollision über den kürzesten Abstand zweier Strecken. Fester Zeitschritt (zum Beispiel 1/120 s) und eine Obergrenze für die Auflösung tiefer Überlappungen verhindern, dass Körper herausgeschleudert werden. Körper gestaffelt übereinander starten lassen, nie ineinander. Die Engine erst bei Sichtbarkeit starten (IntersectionObserver) und außerhalb anhalten. Bei Resize die Wände neu setzen, beim Ziehen die Textauswahl abschalten. Pillen mit Text dürfen ihren Text ab etwa 110° umdrehen, weil die Form symmetrisch ist.

Robust umsetzen

Telefon. Ziehen mit dem Finger ist möglich. touch-action: none nur auf den Container legen, sonst blockiert er das Scrollen der Seite. Körper auf schmalen Bildschirmen kleiner setzen.

Ohne Bewegung. Kein Fallen: die Endlage vorab berechnen und als ruhiges Layout zeigen, gestapelt oder ungeordnet.

Barrierefrei. Eine lesbare Textfassung bereitstellen, etwa die Wörter als echte Liste in der Lesereihenfolge. Die Interaktion ist Dekoration, kein Inhalt darf nur per Ziehen erreichbar sein. Einzelne Buchstaben aria-hidden und den Satz als aria-label am Container.

Code

<!DOCTYPE html>
<!--
  Buchstaben-Physik
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/letter-physics/
  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>Buchstaben-Physik · 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: 'Bricolage Grotesque'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bricolage-grotesque:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Onest'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/onest:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Shantell Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/shantell-sans:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 300 800; font-display: swap; }

/* Welt: Satzweise, Schreibkurse aus Göttingen (Rezept kurs-coaching-lernweg).
   Heller kühler Grund, Tinte fast schwarz, ein Markenton, eine Erfolgsfarbe, Gelb nur für den Start-Hinweis. */
:root {
  --grund: #F4F6FA; --karte: #FFFFFF; --tinte: #141414; --leise: #5a5f6b; --linie: rgba(20, 20, 20, .1);
  --marke: #7491FF; --erfolg: #10BF7A; --gelb: #F7C325;
  --kurve: cubic-bezier(0.22, 1, 0.36, 1);   /* easeOutQuint, Rezept: UI 150–200 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Onest', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); overflow-x: hidden; }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; border-radius: 6px; }
.notiz { font: 450 15px/1.3 'Shantell Sans', 'Comic Sans MS', cursive; font-variation-settings: 'INFM' 40, 'BNCE' 0; color: var(--leise); }
.klein { font: 500 11px/1.3 'Onest', sans-serif; letter-spacing: .12em; text-transform: uppercase; color: var(--leise); }
.nur-vorlesen { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 16px clamp(16px, 4vw, 56px); }
.marke { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.marke svg { width: 30px; height: 30px; flex: none; }
.marke b { font: 800 22px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 48; letter-spacing: -.02em; }
.marke small { margin-left: 4px; font-size: 13px; color: var(--leise); }
.nav { display: flex; gap: 26px; font-size: 15px; font-weight: 500; }
.nav a { text-decoration: none; color: var(--leise); transition: color .15s var(--kurve); }
.nav a:hover { color: var(--tinte); }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 22px; border-radius: 999px; background: var(--tinte); color: #fff; font: 600 15px/1 'Onest', sans-serif; text-decoration: none; white-space: nowrap; transition: background-color .15s var(--kurve), scale .15s var(--kurve); }
.knopf:hover { background: #2c2c2c; }
.knopf:active { scale: .97; }
.knopf.marke-k { background: var(--marke); color: var(--tinte); }
.knopf.marke-k:hover { background: #6280f5; }

/* Bühne */
.buehne { display: grid; grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr); gap: clamp(32px, 5vw, 80px); align-items: center; padding: clamp(16px, 3vh, 40px) clamp(16px, 4vw, 56px) clamp(20px, 3vh, 32px); }
.text { position: relative; }
.text h2 { margin-top: 14px; font: 700 clamp(2.25rem, 1.2rem + 2.6vw, 3.75rem)/1.04 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 96; letter-spacing: -.02em; text-wrap: balance; }
.text .lauf { margin-top: 18px; font-size: 1.0625rem; line-height: 1.6; color: #2b2f37; max-width: 34em; text-wrap: pretty; }
.weg { margin-top: 24px; display: flex; align-items: center; gap: 14px; }
.weg ol { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; width: 168px; padding: 0; list-style: none; }
.weg li { height: 6px; border-radius: 3px; background: var(--erfolg); }
.weg span { font-size: 14px; color: var(--leise); }
.weg b { color: var(--tinte); font-weight: 600; }
.handlung { margin-top: 28px; display: flex; align-items: center; flex-wrap: wrap; gap: 12px 18px; }
.handlung small { font-size: 14px; line-height: 1.4; color: var(--leise); }
.sticker { flex: none; margin-left: auto; width: 88px; height: 88px; margin-block: -24px; border-radius: 50%; background: var(--gelb); display: grid; place-content: center; text-align: center; rotate: 9deg; font: 600 13px/1.15 'Shantell Sans', cursive; font-variation-settings: 'INFM' 40, 'BNCE' 0; }
.sticker b { display: block; font: 800 20px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 24; letter-spacing: -.01em; margin-top: 2px; }

/* Der Kasten mit der Physik */
.kasten { position: relative; height: clamp(380px, 64vh, 580px); border-radius: 26px; background: var(--karte); box-shadow: 0 0 0 1px var(--linie), 0 24px 60px -32px rgba(20, 30, 60, .25); overflow: hidden; isolation: isolate; contain: paint; touch-action: none; user-select: none; -webkit-user-select: none; cursor: grab;
  background-image: radial-gradient(circle at 1px 1px, #dfe4ee 1px, transparent 1.4px); background-size: 24px 24px; background-position: 12px 12px; }
.kasten.greift { cursor: grabbing; }
.kasten .hinweis { position: absolute; left: 22px; top: 18px; display: flex; align-items: flex-start; gap: 6px; pointer-events: none; }
.kasten .hinweis svg { width: 44px; height: 30px; margin-top: 8px; }
.kasten .zahl { position: absolute; right: 22px; top: 22px; pointer-events: none; }
.koerper { position: absolute; left: 0; top: 0; list-style: none; margin: 0; padding: 0; }
.stueck { position: absolute; left: 0; top: 0; visibility: hidden; will-change: transform; transform-origin: 50% 50%; }
.stueck.da { visibility: visible; }
.pille { display: inline-flex; align-items: center; gap: 8px; height: var(--h, 48px); padding: 0 18px 0 8px; border-radius: 999px; background: #fff; box-shadow: inset 0 0 0 1.5px var(--tinte); font: 550 var(--fs, 18px)/1 'Onest', sans-serif; white-space: nowrap; }
.pille i { width: calc(var(--h, 48px) - 16px); height: calc(var(--h, 48px) - 16px); border-radius: 50%; background: var(--erfolg); display: grid; place-items: center; flex: none; }
.pille i svg { width: 55%; height: 55%; }
.pille.blau { background: var(--marke); }
.pille.gelb { background: var(--gelb); }
.pille.tinte { background: var(--tinte); color: #fff; box-shadow: none; }
.zeichen { display: block; font: 800 var(--fs, 110px)/.74 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 96; letter-spacing: 0; height: .74em; padding-top: .015em; color: var(--tinte); text-align: center; }
.zeichen.blau { color: var(--marke); }
.zeichen.gruen { color: var(--erfolg); }

/* Leiste */
.leiste { display: flex; justify-content: space-between; align-items: center; gap: 10px 24px; flex-wrap: wrap; padding: 4px clamp(16px, 4vw, 56px) 20px; }
.modi { display: flex; gap: 4px; padding: 3px; border-radius: 999px; background: #e9edf5; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 0; border-radius: 999px; height: 30px; padding: 0 13px; cursor: pointer; transition: color .15s var(--kurve), background-color .15s var(--kurve); }
.modi button:hover { color: var(--tinte); }
.modi button[aria-pressed='true'] { background: #fff; color: var(--tinte); box-shadow: 0 1px 2px rgba(20, 20, 20, .12); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: flex-end; font-size: 10.5px; }
.werte b { color: var(--tinte); font-weight: 600; }

/* Danach: eine Stimme und der Fuß */
.stimme { margin: clamp(48px, 8vh, 96px) clamp(16px, 4vw, 56px) 0; padding: clamp(28px, 4vw, 48px); border-radius: 26px; background: var(--karte); box-shadow: 0 0 0 1px var(--linie); display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 32px; align-items: end; }
.stimme blockquote { font: 600 clamp(1.375rem, 1rem + 1.2vw, 2rem)/1.25 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 36; letter-spacing: -.01em; text-wrap: balance; }
.stimme p { font-size: 1rem; line-height: 1.55; color: var(--leise); }
.stimme p b { display: block; color: var(--tinte); font-weight: 600; }
.fuss { padding: clamp(48px, 8vh, 88px) clamp(16px, 4vw, 56px) 56px; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 28px; }
.fuss p { font-size: 1rem; line-height: 1.6; color: #2b2f37; }
.fuss .klein { margin-bottom: 8px; }

/* Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 22px; height: 22px; pointer-events: none; display: none; transition: scale .15s var(--kurve); }
.vorfuehrung .zeiger { display: block; }
.zeiger.zu { scale: .82; }

@media (max-width: 1080px) { .nav { display: none; } }
@media (max-width: 900px) {
  .buehne { grid-template-columns: 1fr; }
  .stimme { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
  .marke small { display: none; }
  .kopf .knopf { height: 40px; padding: 0 16px; font-size: 14px; }
  .kasten { height: 560px; border-radius: 20px; }
  .werte { justify-content: flex-start; }
  .werte .lang { display: none; }
}

/* Touch: Ziehen und Werfen mit dem Finger nur im Kasten (touch-action: none), die Seite scrollt daneben */
@media (hover: none) {
  .modi button { height: 36px; padding: 0 15px; }
  .kasten { cursor: auto; }
}

/* Reduzierte Bewegung: Endlage ohne Fallen, Federn aus */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .leiste, .vorschau .stimme, .vorschau .fuss, .vorschau .nav, .vorschau .handlung small { display: none; }
.vorschau .kasten { height: calc(100svh - 120px); }
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#" aria-label="Satzweise, Startseite">
    <svg viewBox="0 0 30 30" aria-hidden="true"><rect width="30" height="30" rx="9" fill="#141414"/><path d="M8 19.5c2.6-1.2 4-3 4-5.6 0-1.8-1-3-2.5-3S7.2 12 7.2 13.4c0 1.3 1 2.3 2.4 2.3M17 19.5c2.6-1.2 4-3 4-5.6 0-1.8-1-3-2.5-3s-2.3 1.1-2.3 2.5c0 1.3 1 2.3 2.4 2.3" fill="none" stroke="#7491FF" stroke-width="2.2" stroke-linecap="round"/></svg>
    <b>Satzweise</b><small>Schreibkurse aus Göttingen</small>
  </a>
  <nav class="nav" aria-label="Hauptnavigation"><a href="#">Der Kurs</a><a href="#">Lernweg</a><a href="#">Über Merle</a><a href="#">Preise</a><a href="#">Fragen</a></nav>
  <a class="knopf" href="#buchen">Platz sichern</a>
</header>

<main>
  <section class="buehne" aria-labelledby="titel">
    <div class="text">
      <p class="notiz">Woche 6 · Abschluss</p>
      <h2 id="titel">Das liegt nach sechs Wochen in deinem Werkzeug&shy;kasten.</h2>
      <p class="lauf">Zwölf Handgriffe für Mails, Berichte und Präsentationen, die du ab Montag nutzt. Greif ruhig zu, sie sind zum Anfassen da.</p>
      <div class="weg"><ol aria-hidden="true"><li></li><li></li><li></li><li></li><li></li><li></li></ol><span><b>6 von 6</b> Modulen geschafft</span></div>
      <div class="handlung">
        <a class="knopf marke-k" href="#buchen">Platz sichern · 390 €</a>
        <small>oder 3 × 130 €<br>14 Tage Rückgaberecht</small>
        <p class="sticker" aria-label="Nächster Start am 11. Januar 2027">Nächster Start<b>11.01.</b></p>
      </div>
    </div>

    <div class="kasten" data-kasten>
      <p class="hinweis notiz" aria-hidden="true"><span data-hinweis>anfassen erlaubt</span><svg viewBox="0 0 44 30"><path d="M3 4c10 1 22 6 30 19m0 0-1-8m1 8-8-2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></p>
      <p class="zahl klein" data-zahl>12 Handgriffe</p>
      <ul class="koerper" data-woerter aria-label="Was du nach dem Kurs kannst">
        <li class="stueck pille"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Leser zuerst</li>
        <li class="stueck pille blau"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Kurze Sätze</li>
        <li class="stueck pille"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Verben statt Nomen</li>
        <li class="stueck pille tinte"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Das Wichtigste nach oben</li>
        <li class="stueck pille"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Aktiv statt Passiv</li>
        <li class="stueck pille gelb"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Ohne Floskeln</li>
        <li class="stueck pille"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Ein Gedanke pro Absatz</li>
        <li class="stueck pille blau"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Betreffzeilen, die wirken</li>
        <li class="stueck pille"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Zahlen statt Adjektive</li>
        <li class="stueck pille"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Streichen lernen</li>
        <li class="stueck pille blau"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Klare Bitte am Ende</li>
        <li class="stueck pille"><i><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 6.3 5 8.6l4.6-5" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></i>Mails in fünf Minuten</li>
      </ul>
      <div class="koerper" data-buchstaben role="img" aria-label="Schreib klar."></div>
    </div>
  </section>

  <div class="leiste">
    <div class="modi klein" role="group" aria-label="Variante">
      <button type="button" data-modus="woerter" aria-pressed="true">Wörter fallen</button>
      <button type="button" data-modus="buchstaben" aria-pressed="false">Buchstaben</button>
      <button type="button" data-modus="federn" aria-pressed="false">Federn</button>
    </div>
    <p class="werte klein" data-werte></p>
  </div>

  <section class="stimme" aria-label="Stimme einer Teilnehmerin">
    <blockquote>„Meine Projektberichte sind halb so lang. Seitdem werden sie auch gelesen.“</blockquote>
    <p><b>Johanna Riedel</b>Teamleiterin Einkauf, Kurs Herbst 2026. Schreibt ihren Wochenbericht jetzt in 20 statt 60 Minuten.</p>
  </section>
  <footer class="fuss">
    <div><p class="klein">Satzweise</p><p>Merle Ostendorf<br>Groner Straße 22, 37073 Göttingen</p></div>
    <div><p class="klein">Kursformat</p><p>6 Wochen online, dienstags 18:30 bis 20:00 Uhr live, Aufzeichnung inklusive</p></div>
    <div><p class="klein">Post von Merle</p><p>Einmal im Monat ein Schreibtipp, den du in zwei Minuten liest. Kein Verkaufsdruck.</p></div>
  </footer>
</main>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#141414" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const kasten = $('[data-kasten]'), woerterListe = $('[data-woerter]'), buchstabenBox = $('[data-buchstaben]');
  const werte = $('[data-werte]'), zeiger = $('[data-zeiger]'), zahl = $('[data-zahl]'), hinweis = $('[data-hinweis]');

  /* Werte. Eigene Verlet-Simulation: jeder Körper ist eine Kapsel aus zwei Punkten.
     Schwerkraft 1 wie Matter.js (1000 px/s²), Rückprall 0,4, Reibung bei Kontakt 0,1 */
  const G = 1000, RUECKPRALL = 0.4, REIBUNG = 0.1, DAEMPFUNG = 0.998;
  const TAKT = 1 / 120, ITERATIONEN = 6, MAXV = 30;   // px pro Schritt (3600 px/s)
  const MAX_KORREKTUR = 3;                             // px je Iteration: tiefe Überlappung löst sich weich statt zu explodieren
  const STAFFEL_MS = 60;                               // Fallstart je Wort/Buchstabe
  const RAND = 10;                                     // Innenabstand der Wände (runde Ecken des Kastens)
  const GRIFF = 0.35;                                  // wie fest der Zeiger zieht
  const KIPP = 110 * Math.PI / 180;                    // Pillen-Text dreht ab ±110° um
  const FEDER_K = 300, FEDER_D = 18, FEDER_RADIUS = 180, FEDER_STOSS = 20000;
  const WORT = 'SCHREIB KLAR.';

  let modus = 'woerter', W = 0, H = 0, koerper = [], start = 0, laeuft = false, rest = 0, letzte = 0;
  let griff = null;   // { k, s, x, y }
  let maus = null;    // für Federn
  let federn = [];

  // Zufall mit festem Startwert, damit jede Runde gleich aussieht
  function zufall(seed) { return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

  /* Buchstaben anlegen */
  [...WORT].forEach((z, i) => {
    if (z === ' ') return;
    const s = document.createElement('span');
    s.className = 'stueck zeichen' + (z === 'K' ? ' blau' : z === '.' ? ' gruen' : '');
    s.textContent = z; s.setAttribute('aria-hidden', 'true'); s.dataset.i = i;
    buchstabenBox.appendChild(s);
  });

  function masse() {
    W = kasten.clientWidth; H = kasten.clientHeight;
    const groesse = W < 520 ? [40, 15] : W < 700 ? [46, 17] : [54, 20];
    kasten.style.setProperty('--h', groesse[0] + 'px');
    kasten.style.setProperty('--fs', groesse[1] + 'px');
    const fsB = Math.round(Math.min(W / 8, H / 4.5, 110));
    buchstabenBox.style.setProperty('--fs', fsB + 'px');
  }

  function koerperAus(el, i, rnd) {
    const w = el.offsetWidth, h = el.offsetHeight;
    // Kapsel entlang der langen Seite; Buchstaben etwas voller (0,56 statt 0,5), damit Ecken kaum überlappen
    const quer = w >= h, r = (quer ? h : w) * (el.classList.contains('zeichen') ? 0.56 : 0.5), L = Math.max(1, Math.abs(w - h));
    // Wörter: zufällig verteilt; Buchstaben: in Lesereihenfolge von links nach rechts, damit der Haufen lesbar bleibt
    const spanne = Math.max(1, W - 2 * RAND - w), n = modus === 'woerter' ? 0 : WORT.replace(/ /g, '').length;
    const x = RAND + (n ? Math.min(1, Math.max(0, i / (n - 1) + (rnd() - 0.5) * 0.08)) : rnd()) * spanne, y = -(h + 14) * (i + 1);   // gestaffelt übereinander: kein Körper startet in einem anderen
    const ang = (rnd() - 0.5) * (n ? 0.5 : 0.8) + (quer ? 0 : Math.PI / 2);
    const cx = x + w / 2, cy = y + h / 2, dx = Math.cos(ang) * L / 2, dy = Math.sin(ang) * L / 2;
    return { el, w, h, r, L, quer, lesbar: el.classList.contains('pille'), kopf: false, start: i * STAFFEL_MS,
      a: { x: cx - dx, y: cy - dy, px: cx - dx, py: cy - dy }, b: { x: cx + dx, y: cy + dy, px: cx + dx, py: cy + dy }, aktiv: false };
  }

  function aufbauen() {
    masse();
    const rnd = zufall(modus === 'woerter' ? 7 : 19);
    griff = null; maus = null;
    woerterListe.hidden = modus !== 'woerter';
    buchstabenBox.hidden = modus === 'woerter';
    zahl.textContent = modus === 'woerter' ? '12 Handgriffe' : 'Merles Kursmotto';
    hinweis.textContent = modus === 'federn' ? 'fahr mal drüber' : 'anfassen erlaubt';
    const els = [...(modus === 'woerter' ? woerterListe : buchstabenBox).querySelectorAll('.stueck')];
    els.forEach(e => e.classList.remove('da'));
    if (modus === 'federn') { koerper = []; federnAufbauen(els); return; }
    koerper = els.map((e, i) => koerperAus(e, i, rnd));
    start = performance.now(); rest = 0;
    if (reduziert()) { vorrechnen(); malen(); }
  }

  /* Federn: Buchstaben in Zeilen, mittig */
  function federnAufbauen(els) {
    const zeilen = W < 520 ? [[0, 7], [7, 12]] : [[0, 12]];
    const fs = parseFloat(buchstabenBox.style.getPropertyValue('--fs')) * (W < 520 ? 1 : 0.86);
    buchstabenBox.style.setProperty('--fs', Math.round(fs) + 'px');
    const breite = els.map(e => e.offsetWidth), hoch = els[0].offsetHeight, abstand = fs * 0.3, zeilenAbstand = hoch * 1.25;
    federn = [];
    const gesamtH = zeilen.length * zeilenAbstand - (zeilenAbstand - hoch);
    zeilen.forEach(([von, bis], z) => {
      let summe = 0;
      for (let i = von; i < bis; i++) summe += breite[i] + (i === 6 && zeilen.length === 1 ? abstand : 0);
      let x = (W - summe) / 2;
      const y = (H - gesamtH) / 2 + z * zeilenAbstand + H * 0.04;
      for (let i = von; i < bis; i++) {
        federn.push({ el: els[i], hx: x, hy: y, w: breite[i], h: hoch, dx: 0, dy: 0, vx: 0, vy: 0 });
        x += breite[i] + (i === 6 && zeilen.length === 1 ? abstand : 0);
      }
    });
    federn.forEach(f => { f.el.classList.add('da'); f.el.style.transform = `translate3d(${f.hx}px, ${f.hy}px, 0)`; });
  }

  /* Kürzeste Punkte zweier Strecken (Ericson, Real-Time Collision Detection) */
  function naechste(p1, q1, p2, q2) {
    const d1x = q1.x - p1.x, d1y = q1.y - p1.y, d2x = q2.x - p2.x, d2y = q2.y - p2.y, rx = p1.x - p2.x, ry = p1.y - p2.y;
    const a = d1x * d1x + d1y * d1y, e = d2x * d2x + d2y * d2y, f = d2x * rx + d2y * ry;
    let s, t;
    const c = d1x * rx + d1y * ry, b = d1x * d2x + d1y * d2y, nenner = a * e - b * b;
    s = nenner > 1e-6 ? Math.min(1, Math.max(0, (b * f - c * e) / nenner)) : 0;
    t = (b * s + f) / e;
    if (t < 0) { t = 0; s = Math.min(1, Math.max(0, -c / a)); } else if (t > 1) { t = 1; s = Math.min(1, Math.max(0, (b - c) / a)); }
    return [s, t];
  }
  function schiebe(k, s, nx, ny) {
    const w1 = 1 - s, w2 = s, n = w1 * w1 + w2 * w2;
    k.a.x += nx * w1 / n; k.a.y += ny * w1 / n; k.b.x += nx * w2 / n; k.b.y += ny * w2 / n;
  }
  function bremse(p) { p.px += (p.x - p.px) * REIBUNG; p.py += (p.y - p.py) * REIBUNG; }

  function schritt(t) {
    const g = G * TAKT * TAKT;
    for (const k of koerper) {
      if (!k.aktiv) { if (t >= k.start) { k.aktiv = true; k.el.classList.add('da'); } else continue; }
      for (const p of [k.a, k.b]) {
        let vx = (p.x - p.px) * DAEMPFUNG, vy = (p.y - p.py) * DAEMPFUNG;
        const v = Math.hypot(vx, vy); if (v > MAXV) { vx *= MAXV / v; vy *= MAXV / v; }
        p.px = p.x; p.py = p.y; p.x += vx; p.y += vy + g;
        // Boden und Wände mit Rückprall (nur einmal pro Schritt)
        const r = k.r;
        if (p.y > H - RAND - r) { const vy2 = p.y - p.py; p.y = H - RAND - r; p.py = vy2 > 1.5 ? p.y + vy2 * RUECKPRALL : p.y; p.px += (p.x - p.px) * REIBUNG; }
        if (p.x < RAND + r) { const vx2 = p.x - p.px; p.x = RAND + r; p.px = p.x + vx2 * RUECKPRALL; }
        if (p.x > W - RAND - r) { const vx2 = p.x - p.px; p.x = W - RAND - r; p.px = p.x + vx2 * RUECKPRALL; }
      }
    }
    for (let it = 0; it < ITERATIONEN; it++) {
      for (let i = 0; i < koerper.length; i++) {
        const k = koerper[i]; if (!k.aktiv) continue;
        // Stab: feste Länge
        const dx = k.b.x - k.a.x, dy = k.b.y - k.a.y, d = Math.hypot(dx, dy) || 1e-6, diff = (d - k.L) / d * 0.5;
        k.a.x += dx * diff; k.a.y += dy * diff; k.b.x -= dx * diff; k.b.y -= dy * diff;
        // Kollision mit den anderen Kapseln
        for (let j = i + 1; j < koerper.length; j++) {
          const m = koerper[j]; if (!m.aktiv) continue;
          const [s, u] = naechste(k.a, k.b, m.a, m.b);
          const px = k.a.x + (k.b.x - k.a.x) * s, py = k.a.y + (k.b.y - k.a.y) * s;
          const qx = m.a.x + (m.b.x - m.a.x) * u, qy = m.a.y + (m.b.y - m.a.y) * u;
          let nx = px - qx, ny = py - qy; const dist = Math.hypot(nx, ny), min = k.r + m.r;
          if (dist >= min) continue;
          if (dist < 1e-6) { nx = 0; ny = -1; } else { nx /= dist; ny /= dist; }
          const halb = Math.min((min - dist) * 0.5, MAX_KORREKTUR);
          schiebe(k, s, nx * halb, ny * halb); schiebe(m, u, -nx * halb, -ny * halb);
          if (it === 0) { bremse(k.a); bremse(k.b); bremse(m.a); bremse(m.b); }
        }
        // Zeiger hält fest
        if (griff && griff.k === k) {
          const s = griff.s, px = k.a.x + (k.b.x - k.a.x) * s, py = k.a.y + (k.b.y - k.a.y) * s;
          schiebe(k, s, (griff.x - px) * GRIFF, (griff.y - py) * GRIFF);
        }
        // Wände ohne Rückprall (nur zurechtrücken)
        for (const p of [k.a, k.b]) { p.x = Math.min(W - RAND - k.r, Math.max(RAND + k.r, p.x)); if (p.y > H - RAND - k.r) p.y = H - RAND - k.r; }
      }
    }
    // Tempo nach den Korrekturen begrenzen; Decke knapp über dem Kasten, damit Würfe zurückkommen
    for (const k of koerper) {
      if (!k.aktiv) continue;
      for (const p of [k.a, k.b]) {
        const vx = p.x - p.px, vy = p.y - p.py, v = Math.hypot(vx, vy);
        if (v > MAXV) { p.px = p.x - vx * MAXV / v; p.py = p.y - vy * MAXV / v; }
        if (p.y < -0.5 * H && vy < 0) { p.y = -0.5 * H; p.py = p.y; }
      }
    }
  }

  function malen() {
    for (const k of koerper) {
      if (!k.aktiv) continue;
      const cx = (k.a.x + k.b.x) / 2, cy = (k.a.y + k.b.y) / 2;
      let ang = Math.atan2(k.b.y - k.a.y, k.b.x - k.a.x) - (k.quer ? 0 : Math.PI / 2);
      if (k.lesbar) {
        // Pillen sind symmetrisch: Text kippt erst jenseits von ±110° um, damit er lesbar bleibt
        let rel = ang + (k.kopf ? Math.PI : 0);
        rel = Math.atan2(Math.sin(rel), Math.cos(rel));
        if (Math.abs(rel) > KIPP) k.kopf = !k.kopf;
        ang += k.kopf ? Math.PI : 0;
      }
      k.el.style.transform = `translate3d(${(cx - k.w / 2).toFixed(1)}px, ${(cy - k.h / 2).toFixed(1)}px, 0) rotate(${ang.toFixed(3)}rad)`;
    }
  }
  // Reduzierte Bewegung: 6 s Simulation vorab rechnen, nur die Endlage zeigen
  function vorrechnen() {
    for (let n = 0; n < 720; n++) schritt(n * TAKT * 1000 + 1e5);
  }

  function federSchritt(dt) {
    for (const f of federn) {
      let ax = -FEDER_K * f.dx - FEDER_D * f.vx, ay = -FEDER_K * f.dy - FEDER_D * f.vy;
      if (maus) {
        const cx = f.hx + f.dx + f.w / 2, cy = f.hy + f.dy + f.h / 2;
        const ex = cx - maus.x, ey = cy - maus.y, d = Math.hypot(ex, ey) || 1;
        if (d < FEDER_RADIUS) { const k = Math.pow(1 - d / FEDER_RADIUS, 2) * FEDER_STOSS; ax += ex / d * k; ay += ey / d * k; }
      }
      f.vx += ax * dt; f.vy += ay * dt; f.dx += f.vx * dt; f.dy += f.vy * dt;
      f.el.style.transform = `translate3d(${(f.hx + f.dx).toFixed(1)}px, ${(f.hy + f.dy).toFixed(1)}px, 0) rotate(${(f.dx * 0.12).toFixed(2)}deg)`;
    }
  }

  function takt(jetzt) {
    requestAnimationFrame(takt);
    const dtMs = Math.min(64, jetzt - letzte); letzte = jetzt;
    if (vorf && !vorfPause) vorfuehrSchritt(jetzt);
    if (!laeuft || reduziert() || vorfPause) return;
    if (modus === 'federn') {
      let n = Math.min(4, Math.round(dtMs / (TAKT * 1000)) || 1);
      while (n--) federSchritt(TAKT);
      return;
    }
    rest += dtMs;
    let n = 0;
    while (rest >= TAKT * 1000 && n < 4) { schritt(jetzt - start); rest -= TAKT * 1000; n++; }
    if (n === 4) rest = 0;
    malen();
  }

  /* Zeiger: greifen, ziehen, werfen */
  function lokal(e) { const r = kasten.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
  function greifen(x, y) {
    let best = null, bestD = 1e9;
    for (const k of koerper) {
      if (!k.aktiv) continue;
      const dx = k.b.x - k.a.x, dy = k.b.y - k.a.y, l2 = dx * dx + dy * dy || 1;
      const s = Math.min(1, Math.max(0, ((x - k.a.x) * dx + (y - k.a.y) * dy) / l2));
      const d = Math.hypot(k.a.x + dx * s - x, k.a.y + dy * s - y) - k.r;
      if (d < bestD) { bestD = d; best = { k, s, x, y }; }
    }
    if (best && bestD < 6) { griff = best; kasten.classList.add('greift'); return true; }
    return false;
  }
  kasten.addEventListener('pointerdown', e => {
    if (reduziert()) return;
    stopVorfuehrung();
    const p = lokal(e);
    if (modus === 'federn') { maus = p; return; }
    if (greifen(p.x, p.y)) { kasten.setPointerCapture(e.pointerId); e.preventDefault(); }
  });
  kasten.addEventListener('pointermove', e => {
    if (vorf || reduziert()) return;
    const p = lokal(e);
    if (modus === 'federn') { maus = p; return; }
    if (griff) { griff.x = p.x; griff.y = p.y; }
  });
  const loslassen = () => { griff = null; kasten.classList.remove('greift'); if (modus === 'federn') maus = null; };
  kasten.addEventListener('pointerup', loslassen);
  kasten.addEventListener('pointercancel', loslassen);
  kasten.addEventListener('pointerleave', () => { if (!vorf && modus === 'federn') maus = null; });

  /* Werte-Anzeige und Umschalter */
  function zeigeWerte() {
    const r = reduziert();
    const physik = `<span>Schwerkraft <b>1 (1000 px/s²)</b></span><span>Rückprall <b>${String(RUECKPRALL).replace('.', ',')}</b></span><span class="lang">Reibung <b>${String(REIBUNG).replace('.', ',')}</b></span><span class="lang">Start <b>${STAFFEL_MS} ms je ${modus === 'woerter' ? 'Wort' : 'Zeichen'}</b></span><span class="lang">Takt <b>120 Hz, ${ITERATIONEN} Iterationen</b></span>`;
    const feder = `<span>Feder <b>k ${FEDER_K}, Dämpfung ${FEDER_D}</b></span><span>Radius <b>${FEDER_RADIUS} px</b></span><span class="lang">Stoß <b>(1 − d/r)² · ${FEDER_STOSS}</b></span>`;
    werte.innerHTML = (modus === 'federn' ? feder : physik) + (r ? '<span>Reduziert: <b>Endlage, kein Fallen</b></span>' : '');
  }
  function setzeModus(neu) {
    modus = neu;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
    aufbauen(); zeigeWerte();
  }
  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]'); if (!b) return;
    stopVorfuehrung(); setzeModus(b.dataset.modus);
  });

  /* Vorführung: fallen lassen, ein Stück greifen und werfen; dann Buchstaben; dann Federn mit gedachtem Zeiger */
  let vorf = false, vorfPause = false, vorfStart = 0, pausiertBei = 0, phaseAlt = -1;
  const PHASEN = [{ modus: 'woerter', dauer: 7600 }, { modus: 'buchstaben', dauer: 6400 }, { modus: 'federn', dauer: 5200 }];
  const GESAMT = PHASEN.reduce((s, p) => s + p.dauer, 0);
  const glatt = x => x * x * (3 - 2 * x);
  function zeigerAuf(x, y, zu) {
    const r = kasten.getBoundingClientRect();
    zeiger.style.transform = `translate(${r.left + x - 3}px, ${r.top + y - 2}px)`;
    zeiger.classList.toggle('zu', !!zu);
  }
  function wurf(q, nr, ziel) {
    // q 0..1: hinfahren (bis 0,3), greifen, mit zunehmendem Tempo zum Ziel ziehen, bei 0,85 loslassen (Wurf)
    const k = koerper[nr]; if (!k || !k.aktiv) return;
    const cx = (k.a.x + k.b.x) / 2, cy = (k.a.y + k.b.y) / 2;
    if (q < 0.3) { const t = glatt(q / 0.3); zeigerAuf(W * 0.92 + (cx - W * 0.92) * t, H * 0.12 + (cy - H * 0.12) * t, false); return; }
    if (q < 0.85) {
      if (!griff) griff = { k, s: 0.5, x: cx, y: cy, x0: cx, y0: cy };
      const t = Math.pow(Math.min(1, (q - 0.35) / 0.5), 2);   // beschleunigt bis zum Loslassen
      if (q >= 0.35) { griff.x = griff.x0 + (ziel.x - griff.x0) * t; griff.y = griff.y0 + (ziel.y - griff.y0) * t; }
      zeigerAuf(griff.x, griff.y, true);
      return;
    }
    griff = null; zeigerAuf(ziel.x, ziel.y, false);
  }
  function vorfuehrSchritt(jetzt) {
    let t = (jetzt - vorfStart) % GESAMT, i = 0;
    while (t > PHASEN[i].dauer) { t -= PHASEN[i].dauer; i++; }
    const ph = PHASEN[i];
    if (i !== phaseAlt) { phaseAlt = i; griff = null; maus = null; setzeModus(ph.modus); zeigerAuf(W + 40, -40, false); }
    if (ph.modus === 'federn') {
      const q = t / ph.dauer, rund = Math.min(1, Math.max(0, (q - 0.08) / 0.84));
      const x = W * (0.08 + 0.84 * rund), y = H * 0.5 + Math.sin(rund * Math.PI * 3) * H * 0.12;
      maus = q > 0.06 && q < 0.94 ? { x, y } : null;
      zeigerAuf(x, y, false);
    } else {
      const fenster = ph.modus === 'woerter' ? [2600, 5600] : [2400, 5000];
      if (t > fenster[0] && t < fenster[1]) {
        const q = (t - fenster[0]) / (fenster[1] - fenster[0]);
        wurf(q, ph.modus === 'woerter' ? 3 : 7, { x: W * 0.28, y: H * 0.12 });
      } else if (t >= fenster[1]) { griff = null; zeigerAuf(W * 0.3, H * 0.18, false); }
      else zeigerAuf(W + 40, -40, false);
    }
  }
  function vorfuehren() {
    stopVorfuehrung();
    if (reduziert()) { setzeModus('woerter'); return; }
    vorf = true; vorfPause = false; vorfStart = performance.now(); phaseAlt = -1;
    html.classList.add('vorfuehrung');
  }
  function stopVorfuehrung() {
    if (!vorf) return;
    vorf = false; vorfPause = false; griff = null; maus = null;
    html.classList.remove('vorfuehrung');
  }
  function pause() { if (vorf && !vorfPause) { vorfPause = true; pausiertBei = performance.now(); } else laeuft = false; }
  function weiter() {
    if (vorf && vorfPause) { vorfStart += performance.now() - pausiertBei; start += performance.now() - pausiertBei; vorfPause = false; }
    else if (!vorf && html.classList.contains('vorschau')) vorfuehren();
    laeuft = sichtbar;
  }
  function neustart() { stopVorfuehrung(); setzeModus(modus); }

  // Nur rechnen, solange der Kasten sichtbar ist; erster Fall beim Eintritt
  let sichtbar = false, gestartet = false;
  new IntersectionObserver(es => {
    sichtbar = es[0].isIntersecting; laeuft = sichtbar;
    if (sichtbar && !gestartet) { gestartet = true; if (!vorf) aufbauen(); }
  }, { threshold: 0.25 }).observe(kasten);

  let breiteAlt = 0, rz = null;
  new ResizeObserver(() => {
    clearTimeout(rz);
    rz = setTimeout(() => { if (kasten.clientWidth !== breiteAlt) { breiteAlt = kasten.clientWidth; aufbauen(); } else { masse(); } }, 120);
  }).observe(kasten);

  document.fonts.ready.then(() => {
    breiteAlt = kasten.clientWidth;
    zeigeWerte();
    if (sichtbar && !vorf) aufbauen();
    letzte = performance.now();
    requestAnimationFrame(takt);
  });

})();
</script>
</body>
</html>