Figuren-/Maskottchen-Zustände
Lernbegleiter Kalle in einem Kurs-Quiz: atmet, blinzelt, schaut dem Zeiger nach, denkt beim Prüfen, hüpft bei der richtigen Antwort und lässt bei der falschen den Trieb hängen.
Eine kleine SVG-Figur, etwa ein Logo-Charakter, Avatar oder nur Augen, hat Ruhe-Animationen: Sie atmet und blinzelt in zufälligen Abständen. Dazu kommen Zustände wie denkt, freut sich oder schaut zum Zeiger. Zwischen den Zuständen wechseln GSAP-Timelines oder Rive-State-Machines. Die Figur reagiert auf Ereignisse der Oberfläche: Formular gesendet, Fehler, Laden.
Die Figur gibt dem Produkt Persönlichkeit und Wärme. Seit KI-Assistenten eigene Maskottchen haben, ist das Thema 2026 wieder stärker im Gespräch.
Wann einsetzen
Marken mit Charakter, Lade- und Leerzustände, Onboarding, 404-Seiten, Kontaktformulare, Lern- und Quiz-Oberflächen.
Wann nicht
In nüchternen B2B-Kontexten. Wenn die Figur Inhalte verdeckt oder dauernd zappelt.
Varianten
- SVG mit GSAP-Timelines pro Zustand
- Augen folgen dem Zeiger (atan2)
- Rive-State-Machine
- Lottie für einfache Loops
Umsetzung
Blinzeln: gsap.timeline().to(augen, { scaleY: .1, duration: .08, transformOrigin: 'center' }).to(augen, { scaleY: 1, duration: .12 }), danach mit zufälliger Pause von 2 bis 6 s neu starten. Atmen: Skalierung 1 → 1,02 als yoyo über 2 bis 3 s mit sine.inOut. Für jeden Zustand eine eigene Timeline; beim Wechsel die laufende beenden und kurz über eine neutrale Pose gehen. Münder mit gleicher Pfadstruktur lassen sich per attr: { d } ohne Morph-Plugin überblenden. Blick: Winkel per Math.atan2 zur Figurmitte, Pupillen per gsap.quickTo um wenige Pixel verschieben.
Robust umsetzen
Telefon. Identisch. Ohne Hover schaut die Figur kurz zur Tippstelle.
Ohne Bewegung. Statische Pose. Zustände wechseln ohne Übergang und ohne Loop.
Barrierefrei. Die Figur ist aria-hidden, wenn sie rein dekorativ ist. Zustände zusätzlich als Text zeigen, etwa in einer Sprechblase mit aria-live="polite".
Code
<!DOCTYPE html>
<!--
Figuren-/Maskottchen-Zustände
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/maskottchen-zustaende/
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>Figuren-/Maskottchen-Zustände · 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; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept kurs-coaching-lernweg */
--grund: #f4f6fa; --karte: #ffffff; --text: #141414; --meta: #5b6170; --linie: #e1e5ee;
--marke: #7491ff; --marke-tief: #4a64d8; --marke-hell: #e6ebff; --erfolg: #10bf7a; --erfolg-tief: #0b8f5b; --gelb: #f7c325;
--quint: cubic-bezier(0.22, 1, 0.36, 1); /* easeOutQuint: Basis */
--feder: cubic-bezier(0.34, 1.56, 0.64, 1); /* nur für Erfolg */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Onest', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: hidden; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.hand { font-family: 'Shantell Sans', 'Onest', sans-serif; font-variation-settings: 'INFM' 40, 'BNCE' 0; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Kopf */
.kopf { display: flex; align-items: center; gap: 28px; height: 68px; padding: 0 clamp(16px, 4vw, 48px); }
.marke { display: flex; align-items: center; gap: 9px; margin-right: auto; color: var(--text); text-decoration: none; font: 800 22px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 48; letter-spacing: -.02em; }
.marke svg { width: 28px; height: 28px; }
.nav { display: flex; gap: 26px; list-style: none; padding: 0; }
.nav a { color: var(--text); text-decoration: none; font-size: 15px; font-weight: 500; }
.nav a[aria-current] { color: var(--marke-tief); }
.cta { display: inline-flex; align-items: center; height: 40px; padding: 0 18px; border-radius: 999px; background: var(--text); color: #fff; font-size: 14.5px; font-weight: 600; text-decoration: none; transition: background-color .2s var(--quint); }
.cta:hover { background: var(--marke-tief); }
/* Bühne */
.buehne { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(20px, 3vw, 40px); max-width: 1280px; margin: 0 auto; padding: clamp(8px, 2vw, 24px) clamp(16px, 4vw, 48px) clamp(24px, 3vw, 40px); }
/* Lektion */
.lektion { background: var(--karte); border-radius: 20px; padding: clamp(22px, 3vw, 36px); display: flex; flex-direction: column; }
.weg { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.modul { font-size: .9375rem; color: var(--marke-tief); }
.segmente { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; margin: 12px 0 6px; }
.segmente i { position: relative; height: 6px; border-radius: 3px; background: var(--linie); overflow: hidden; }
.segmente i::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: var(--erfolg); transform: scaleX(0); transform-origin: left; }
.segmente i.fertig::after { transform: scaleX(1); }
.segmente i.jetzt { background: var(--marke-hell); }
.segmente i.jetzt::before { content: ''; position: absolute; inset: 0 60% 0 0; border-radius: inherit; background: var(--marke); }
.segmente i.geschafft::after { transform: scaleX(1); transition: transform .5s var(--feder); }
.stand { font-size: .8125rem; color: var(--meta); font-variant-numeric: tabular-nums; }
.lektion h1 { font: 700 clamp(1.75rem, 1.2rem + 1.8vw, 2.625rem)/1.06 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 72; letter-spacing: -.02em; margin: 22px 0 10px; text-wrap: balance; }
.lektion .einleitung { font-size: 1.0625rem; line-height: 1.6; color: #3b3f48; max-width: 52ch; text-wrap: pretty; }
.antworten { display: grid; gap: 10px; margin: 20px 0 0; padding: 0; border: 0; }
.antwort input { position: absolute; opacity: 0; pointer-events: none; }
.antwort label { display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 14px; padding: 12px 14px; border: 1.5px solid var(--linie); border-radius: 14px; cursor: pointer; transition: border-color .15s var(--quint), background-color .15s var(--quint); }
.antwort label:hover { border-color: #c4cdf5; }
.antwort b { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; background: var(--grund); font: 700 15px/1 'Bricolage Grotesque', sans-serif; transition: background-color .15s var(--quint), color .15s var(--quint); }
.antwort strong { display: block; font-weight: 600; font-size: 1rem; }
.antwort small { display: block; font-size: .875rem; color: var(--meta); margin-top: 2px; }
.antwort input:checked + label { border-color: var(--marke); background: #f6f8ff; }
.antwort input:checked + label b { background: var(--marke); color: #fff; }
.antwort input:focus-visible + label { outline: 2px solid var(--marke-tief); outline-offset: 2px; }
.antwort.falsch label { border-color: #f0b7a8; background: #fff7f4; }
.antwort.falsch b { background: #e8765a !important; color: #fff; }
.antwort.richtig label { border-color: var(--erfolg); background: #effbf5; }
.antwort.richtig b { background: var(--erfolg) !important; color: #fff; }
.aktion { display: flex; align-items: center; gap: 14px; margin-top: 20px; flex-wrap: wrap; }
.pruefen { height: 48px; padding: 0 22px; border: 0; border-radius: 999px; background: var(--marke); color: #fff; font-weight: 600; font-size: 1rem; cursor: pointer; transition: background-color .15s var(--quint), scale .15s var(--quint); }
.pruefen:hover { background: var(--marke-tief); }
.pruefen:active, .pruefen.gedrueckt { scale: .97; }
.pruefen:focus-visible { outline: 2px solid var(--marke-tief); outline-offset: 3px; }
.pruefen[disabled] { opacity: .45; cursor: default; }
.rueckmeldung { font-size: .9375rem; color: var(--meta); }
.rueckmeldung.gut { color: var(--erfolg-tief); font-weight: 600; }
/* Maskottchen-Bühne */
.figur-buehne { position: relative; display: grid; grid-template-rows: auto 1fr auto; border-radius: 20px; background: var(--marke-hell); overflow: hidden; min-height: 460px; }
.figur-buehne::before { content: ''; position: absolute; left: 50%; bottom: -28%; width: 120%; aspect-ratio: 1; translate: -50% 0; border-radius: 50%; background: #d9e0ff; }
.blase { position: relative; z-index: 1; margin: clamp(18px, 2.4vw, 28px) clamp(18px, 2.4vw, 28px) 0; padding: 14px 16px; background: #fff; border-radius: 16px 16px 16px 4px; font-size: 1.0625rem; line-height: 1.45; max-width: 30ch; min-height: 3.6em; box-shadow: 0 10px 30px -18px rgba(40, 60, 160, .5); }
.blase .hand { display: block; font-size: .875rem; color: var(--marke-tief); margin-bottom: 4px; }
.figur { position: relative; z-index: 1; display: grid; place-items: center; }
.figur svg { width: min(100%, 400px); height: auto; overflow: visible; }
.figur-fuss { position: relative; z-index: 1; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 0 clamp(18px, 2.4vw, 28px) 18px; }
.zustand-chip { display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 12px; border-radius: 999px; background: rgba(255, 255, 255, .75); }
.zustand-chip::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--marke); }
.figur-buehne[data-zustand='freut'] .zustand-chip::before { background: var(--erfolg); }
.figur-buehne[data-zustand='oops'] .zustand-chip::before { background: #e8765a; }
/* Demo-Bedienung */
.pult { max-width: 1280px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 48px) 36px; }
.pult-innen { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; padding-top: 14px; border-top: 1px solid var(--linie); }
.modi { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.modi .mono { margin-right: 6px; }
.modi button { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); background: none; border: 1px solid #cfd5e2; border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s var(--quint), background-color .15s var(--quint), border-color .15s var(--quint); }
.modi button:hover { color: var(--text); border-color: #a9b2c6; }
.modi button[aria-pressed='true'] { background: var(--text); color: #fff; border-color: var(--text); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; flex-basis: 100%; }
.werte b { color: var(--text); font-weight: 500; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 20px; height: 20px; pointer-events: none; display: none; transition: scale .15s var(--quint); }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueck { scale: .82; }
/* Vorschau 960 × 720 */
.vorschau body { overflow: hidden; }
.vorschau .pult, .vorschau .nav, .vorschau .lektion .einleitung { display: none; }
.vorschau .figur-buehne { min-height: 0; }
/* Reduziert: statische Posen, Wechsel ohne Loop */
.reduziert .segmente i.geschafft::after { transition: none; }
@media (max-width: 1000px) { .nav { display: none; } }
@media (max-width: 820px) {
.buehne { grid-template-columns: 1fr; }
.figur-buehne { order: -1; min-height: 0; grid-template-columns: minmax(0, 1fr) 150px; grid-template-rows: 1fr auto; }
.blase { grid-column: 1; grid-row: 1; align-self: center; margin: 16px 0 0 16px; font-size: .9375rem; }
.figur { grid-column: 2; grid-row: 1 / 3; padding: 10px 8px 6px 0; }
.figur-fuss { grid-column: 1; grid-row: 2; padding: 10px 0 14px 16px; }
.figur-fuss .hand { display: none; }
}
@media (max-width: 520px) {
.cta { height: 36px; padding: 0 14px; font-size: 13.5px; }
.antwort label { grid-template-columns: 30px 1fr; gap: 12px; padding: 11px 12px; }
.antwort b { width: 30px; height: 30px; }
.figur-buehne { grid-template-columns: minmax(0, 1fr) 124px; }
}
@media (hover: none) { .zeiger { display: none !important; } .antwort label:hover { border-color: var(--linie); } }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#start"><svg viewBox="0 0 28 28" aria-hidden="true"><rect x="2" y="12" width="24" height="14" rx="7" fill="#7491ff"/><path d="M14 13c0-4 0-6 1-8" stroke="#10bf7a" stroke-width="2.6" stroke-linecap="round" fill="none"/><path d="M15 6c-3-3-7-3-8-1 2 2 6 3 8 1Zm0 0c2-4 6-4 7-2-1 2-5 4-7 2Z" fill="#10bf7a"/></svg>Beetschule</a>
<ul class="nav">
<li><a href="#kurs">Kurs</a></li><li><a href="#lernweg" aria-current="page">Lernweg</a></li>
<li><a href="#ines">Über Thekla</a></li><li><a href="#preise">Preise</a></li>
</ul>
<a class="cta" href="#konto">Mein Kurs</a>
</header>
<main class="buehne" id="start">
<section class="lektion" aria-labelledby="frage">
<div class="weg">
<p class="modul hand">Modul 03 · Boden & Schichten · 12 min</p>
<p class="mono">Dein erstes Hochbeet</p>
</div>
<div class="segmente" aria-hidden="true"><i class="fertig"></i><i class="fertig"></i><i class="jetzt" data-segment></i><i></i><i></i><i></i><i></i><i></i></div>
<p class="stand" data-stand>2 von 8 Modulen geschafft</p>
<h1 id="frage">Was gehört ganz unten ins Hochbeet?</h1>
<p class="einleitung">Ein Hochbeet ist aufgebaut wie eine Lasagne. Die unterste Schicht entscheidet, ob Wasser abläuft und ob die Wurzeln Luft bekommen.</p>
<form data-quiz novalidate>
<fieldset class="antworten">
<legend class="sr">Antwort wählen</legend>
<div class="antwort" data-antwort="a"><input type="radio" name="antwort" id="a-a" value="a"><label for="a-a"><b>A</b><span><strong>Reifer Kompost</strong><small>Fein, nährstoffreich, sofort pflanzbereit</small></span></label></div>
<div class="antwort" data-antwort="b"><input type="radio" name="antwort" id="a-b" value="b"><label for="a-b"><b>B</b><span><strong>Grobe Äste und Strauchschnitt</strong><small>Sperrig, luftig, verrottet über Jahre</small></span></label></div>
<div class="antwort" data-antwort="c"><input type="radio" name="antwort" id="a-c" value="c"><label for="a-c"><b>C</b><span><strong>Gekaufte Pflanzerde</strong><small>Locker und sauber, in der Menge teuer</small></span></label></div>
</fieldset>
<div class="aktion">
<button class="pruefen" type="submit" data-pruefen disabled>Antwort prüfen</button>
<p class="rueckmeldung" data-rueckmeldung>Wähle eine Antwort.</p>
</div>
</form>
</section>
<section class="figur-buehne" data-buehne data-zustand="ruhe" aria-label="Lernbegleiter Kalle">
<p class="blase" aria-live="polite"><span class="hand">Kalle sagt</span><span data-blase>Lies dir die drei Antworten in Ruhe durch. Ich schau mit.</span></p>
<div class="figur">
<svg viewBox="0 0 260 250" aria-hidden="true" data-svg>
<ellipse data-schatten cx="130" cy="224" rx="60" ry="8" fill="#3b4aa0" opacity=".16"/>
<g data-gedanke>
<circle cx="196" cy="98" r="4.5" fill="#fff"/><circle cx="211" cy="80" r="6.5" fill="#fff"/><circle cx="231" cy="58" r="10" fill="#fff"/>
</g>
<g data-zzz font-family="Bricolage Grotesque, sans-serif" font-weight="800" fill="#4a64d8">
<text x="186" y="96" font-size="16">z</text><text x="198" y="80" font-size="21">z</text><text x="212" y="60" font-size="27">Z</text>
</g>
<g data-figur>
<g data-koerper>
<ellipse cx="106" cy="212" rx="15" ry="7.5" fill="#4a64d8"/><ellipse cx="154" cy="212" rx="15" ry="7.5" fill="#4a64d8"/>
<g data-trieb>
<path d="M130 76c0-10-1.5-20 1.5-31" stroke="#10bf7a" stroke-width="5" stroke-linecap="round" fill="none"/>
<path data-blatt-l d="M131 46c-12-14-30-13-35-5 7 10 24 13 35 5Z" fill="#10bf7a"/>
<path data-blatt-r d="M131 46c10-18 30-19 37-11-7 12-26 17-37 11Z" fill="#0fae6f"/>
<g data-bluete transform="translate(131 42)">
<circle cx="0" cy="-9" r="6" fill="#f7c325"/><circle cx="8.6" cy="-2.8" r="6" fill="#f7c325"/><circle cx="5.3" cy="7.3" r="6" fill="#f7c325"/><circle cx="-5.3" cy="7.3" r="6" fill="#f7c325"/><circle cx="-8.6" cy="-2.8" r="6" fill="#f7c325"/>
<circle r="4.6" fill="#e09a00"/>
</g>
</g>
<path d="M130 72c53 0 77 34 77 79 0 46-31 64-77 64s-77-18-77-64c0-45 24-79 77-79Z" fill="#7491ff"/>
<ellipse cx="130" cy="192" rx="46" ry="20" fill="#86a0ff"/>
<ellipse cx="94" cy="104" rx="14" ry="8" transform="rotate(-32 94 104)" fill="#fff" opacity=".35"/>
<g data-gesicht>
<ellipse data-wange cx="90" cy="162" rx="10" ry="5.5" fill="#ff90e0" opacity=".45"/>
<ellipse data-wange cx="170" cy="162" rx="10" ry="5.5" fill="#ff90e0" opacity=".45"/>
<g data-augen>
<g data-auge><ellipse cx="108" cy="140" rx="13" ry="15" fill="#fff"/><g data-pupille><circle cx="108" cy="141" r="7.2" fill="#141414"/><circle cx="105.5" cy="137.6" r="2.2" fill="#fff"/></g></g>
<g data-auge><ellipse cx="152" cy="140" rx="13" ry="15" fill="#fff"/><g data-pupille><circle cx="152" cy="141" r="7.2" fill="#141414"/><circle cx="149.5" cy="137.6" r="2.2" fill="#fff"/></g></g>
</g>
<g data-froh fill="none" stroke="#141414" stroke-width="4.5" stroke-linecap="round"><path d="M96 145q12-14 24 0"/><path d="M140 145q12-14 24 0"/></g>
<g data-zu fill="none" stroke="#141414" stroke-width="4" stroke-linecap="round"><path d="M97 141q11 9 22 0"/><path d="M141 141q11 9 22 0"/></g>
<path data-braue-l d="M99 119 117 121" stroke="#141414" stroke-width="3.6" stroke-linecap="round"/>
<path data-braue-r d="M143 121 161 119" stroke="#141414" stroke-width="3.6" stroke-linecap="round"/>
<path data-mund d="M118 168Q130 177 142 168Q130 172 118 168Z" fill="#141414" stroke="#141414" stroke-width="3.6" stroke-linejoin="round"/>
</g>
</g>
</g>
</svg>
</div>
<div class="figur-fuss">
<span class="zustand-chip mono" data-chip>Ruhe</span>
<span class="hand" style="font-size:.875rem;color:#4a64d8">Kalle, dein Lernbegleiter</span>
</div>
</section>
</main>
<div class="pult">
<div class="pult-innen">
<div class="modi" role="group" aria-label="Zustand">
<span class="mono">Zustand</span>
<button type="button" data-z="ruhe" aria-pressed="true">Ruhe</button>
<button type="button" data-z="denkt" aria-pressed="false">Denkt</button>
<button type="button" data-z="freut" aria-pressed="false">Freut sich</button>
<button type="button" data-z="oops" aria-pressed="false">Oops</button>
<button type="button" data-z="schlaeft" aria-pressed="false">Schläft</button>
</div>
<div class="modi" role="group" aria-label="Blick">
<span class="mono">Blick</span>
<button type="button" data-blick aria-pressed="true">Folgt dem Zeiger</button>
</div>
<p class="werte mono" data-werte></p>
</div>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2.5 1.5 17 10l-6.6 1.4-3.2 6.1Z" fill="#141414" stroke="#ffffff" stroke-width="1.3" stroke-linejoin="round"/></svg>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/CustomEase.min.js"></script>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
gsap.registerPlugin(CustomEase);
gsap.defaults({ overwrite: 'auto' }); // neue Pose übernimmt Eigenschaften, die der Weg über neutral gerade noch tweent
CustomEase.create('quint', '0.22, 1, 0.36, 1'); // Rezept: easeOutQuint
CustomEase.create('feder', '0.34, 1.56, 0.64, 1'); // Rezept: Überschwinger nur bei Erfolg
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const svg = $('[data-svg]'), figur = $('[data-figur]'), koerper = $('[data-koerper]'), trieb = $('[data-trieb]');
const augen = $$('[data-auge]'), pupillen = $$('[data-pupille]'), froh = $('[data-froh]'), zu = $('[data-zu]');
const braueL = $('[data-braue-l]'), braueR = $('[data-braue-r]'), mund = $('[data-mund]'), gesicht = $('[data-gesicht]');
const bluete = $('[data-bluete]'), gedanke = $('[data-gedanke]'), zzz = $('[data-zzz]'), schatten = $('[data-schatten]');
const wangen = $$('[data-wange]'), blattL = $('[data-blatt-l]'), blattR = $('[data-blatt-r]');
const buehne = $('[data-buehne]'), blase = $('[data-blase]'), chip = $('[data-chip]'), werte = $('[data-werte]');
/* Münder: gleiche Pfadstruktur (M Q Q Z), damit GSAP die Zahlen direkt überblenden kann */
const MUND = {
laecheln: 'M118 168Q130 177 142 168Q130 172 118 168Z',
offen: 'M112 163Q130 192 148 163Q130 170 112 163Z',
flach: 'M121 170Q130 171 139 170Q130 171 121 170Z',
o: 'M125 171Q130 180 135 171Q130 163 125 171Z',
traurig: 'M119 175Q130 165 141 175Q130 170 119 175Z',
schlaf: 'M124 172Q130 175 136 172Q130 173 124 172Z'
};
const ZUSTAND = {
ruhe: { name: 'Ruhe', text: 'Lies dir die drei Antworten in Ruhe durch. Ich schau mit.',
werte: ['Atmen <b>scale 1 → 1,02</b>, <b>1400 ms</b> sine.inOut, yoyo', 'Blinzeln <b>80 + 120 ms</b>, Pause <b>2–6 s</b> zufällig', 'Blick <b>300 ms</b> easeOutQuint, Pupille max. <b>6 px</b>', 'Trieb wiegt <b>±4°</b>, <b>2600 ms</b>'] },
denkt: { name: 'Denkt', text: 'Moment, ich prüfe deine Antwort …',
werte: ['Wechsel <b>200 ms</b> easeOutQuint', 'Blick oben rechts', 'Gedankenpunkte <b>120 ms</b> versetzt, Puls <b>900 ms</b> sine.inOut', 'Wiegen <b>±2°</b>, <b>1200 ms</b>'] },
freut: { name: 'Freut sich', text: 'Genau! Äste unten lassen Wasser ablaufen und halten das Beet jahrelang locker.',
werte: ['Ducken <b>120 ms</b> scale 1,08 / 0,9', 'Sprung <b>−34 px</b>, <b>320 ms</b> power2.out, Landung <b>280 ms</b> power2.in', 'Nachfedern <b>500 ms</b> cubic-bezier(0.34, 1.56, 0.64, 1)', 'Blüte <b>0 → 1</b>, <b>500 ms</b> feder'] },
oops: { name: 'Oops', text: 'Fast. Kompost kommt weiter oben hin. Was sorgt ganz unten für Luft?',
werte: ['Kopfschütteln <b>±4 px</b>, <b>3 × 80 ms</b> sine.inOut', 'Trieb hängt <b>−24°</b>, <b>400 ms</b> easeOutQuint', 'Brauen innen hoch <b>±22°</b>', 'Körper sinkt <b>scaleY 0,97</b>'] },
schlaeft: { name: 'Schläft', text: 'Kalle macht Pause. Weiter geht es, wann du willst.',
werte: ['Atmen <b>scale 1 → 1,035</b>, <b>2400 ms</b> sine.inOut', 'Z steigt <b>30 px</b> in <b>2400 ms</b>, <b>800 ms</b> versetzt', 'Augen zu, Trieb <b>−12°</b>'] }
};
/* Ausgangspose */
gsap.set([froh, zu, gedanke, zzz, braueL, braueR], { opacity: 0 });
gsap.set(bluete, { scale: 0, transformOrigin: '50% 50%' });
gsap.set(trieb, { svgOrigin: '130 76' });
gsap.set(koerper, { svgOrigin: '130 216' });
gsap.set(augen, { transformOrigin: '50% 50%' });
gsap.set([braueL, braueR], { transformOrigin: '50% 50%' });
gsap.set(schatten, { transformOrigin: '50% 50%' });
gsap.set(zzz.children, { opacity: 0 });
gsap.set(gedanke.children, { transformOrigin: '50% 50%' });
let zustand = 'ruhe', zTl = null, schleifen = [], atem = null, blickAn = true, blinzelUhr = null;
/* Atmen: dauernd, nur Tempo und Tiefe ändern sich */
function atmen(tief) {
if (atem) atem.kill();
gsap.set(koerper, { scaleY: 1, scaleX: 1 });
if (reduziert()) return;
atem = gsap.to(koerper, { scaleY: tief ? 1.035 : 1.02, scaleX: tief ? 1.01 : 1.005, duration: tief ? 2.4 : 1.4, ease: 'sine.inOut', yoyo: true, repeat: -1 });
}
/* Blinzeln in zufälligen Abständen, gelegentlich doppelt */
function blinzeln() {
clearTimeout(blinzelUhr);
blinzelUhr = setTimeout(() => {
if (!reduziert() && (zustand === 'ruhe' || zustand === 'denkt' || zustand === 'oops')) {
const tl = gsap.timeline();
tl.to(augen, { scaleY: .1, duration: .08, ease: 'sine.inOut' }).to(augen, { scaleY: 1, duration: .12, ease: 'sine.inOut' });
if (Math.random() < .2) tl.to(augen, { scaleY: .1, duration: .08, ease: 'sine.inOut' }, '+=.08').to(augen, { scaleY: 1, duration: .12, ease: 'sine.inOut' });
}
blinzeln();
}, gsap.utils.random(2000, 6000));
}
/* Blick: Pupillen per quickTo, Gesicht wandert leicht mit */
const px = pupillen.map(p => gsap.quickTo(p, 'x', { duration: .3, ease: 'quint' }));
const py = pupillen.map(p => gsap.quickTo(p, 'y', { duration: .3, ease: 'quint' }));
const gx = gsap.quickTo(gesicht, 'x', { duration: .3, ease: 'quint' }), gy = gsap.quickTo(gesicht, 'y', { duration: .3, ease: 'quint' });
function schaue(dx, dy, sofort) {
if (sofort || reduziert()) { gsap.set(pupillen, { x: dx * 6, y: dy * 6 }); gsap.set(gesicht, { x: dx * 3, y: dy * 2 }); return; }
px.forEach(f => f(dx * 6)); py.forEach(f => f(dy * 6)); gx(dx * 3); gy(dy * 2);
}
function schaueAuf(x, y) {
if (zustand !== 'ruhe' || !blickAn || reduziert()) return;
const r = svg.getBoundingClientRect(), cx = r.left + r.width * .5, cy = r.top + r.height * .56;
const w = Math.atan2(y - cy, x - cx), d = Math.min(1, Math.hypot(x - cx, y - cy) / 320);
schaue(Math.cos(w) * d, Math.sin(w) * d);
}
addEventListener('pointermove', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) { weckAuf(); schaueAuf(e.clientX, e.clientY); } });
let mitteUhr = 0;
addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') { weckAuf(); schaueAuf(e.clientX, e.clientY); clearTimeout(mitteUhr); mitteUhr = setTimeout(() => zustand === 'ruhe' && schaue(0, 0), 1200); } });
/* Zustände: jede Pose ist eine Timeline, Wechsel beendet die alte und geht über neutral */
function neutral(tl, d) {
tl.to([froh, zu, gedanke, zzz, braueL, braueR], { opacity: 0, duration: d, ease: 'quint' }, 0)
.to(augen, { opacity: 1, scaleY: 1, duration: d, ease: 'quint' }, 0)
.to([braueL, braueR], { rotation: 0, y: 0, duration: d, ease: 'quint' }, 0)
.to(trieb, { rotation: 0, duration: d, ease: 'quint' }, 0)
.to(figur, { x: 0, y: 0, rotation: 0, duration: d, ease: 'quint' }, 0)
.to(bluete, { scale: 0, duration: d, ease: 'quint' }, 0)
.to([blattL, blattR], { scale: 1, rotation: 0, duration: d, ease: 'quint' }, 0)
.to(wangen, { opacity: .45, duration: d, ease: 'quint' }, 0)
.to(schatten, { scaleX: 1, opacity: .16, duration: d, ease: 'quint' }, 0)
.to(zzz.children, { opacity: 0, y: 0, duration: d }, 0);
}
function setze(z, still) {
if (z === zustand && !still) return;
zustand = z;
if (zTl) zTl.kill(); schleifen.forEach(s => s.kill()); schleifen = [];
const d = reduziert() ? 0 : .2;
const tl = zTl = gsap.timeline();
neutral(tl, d);
buehne.dataset.zustand = z; chip.textContent = ZUSTAND[z].name; blase.textContent = ZUSTAND[z].text;
$$('[data-z]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.z === z)));
zeigeWerte();
atmen(z === 'schlaeft');
const R = reduziert(), loop = t => { if (!R) schleifen.push(t); else t.revert(); };
if (z === 'ruhe') {
tl.to(mund, { attr: { d: MUND.laecheln }, duration: d, ease: 'quint' }, 0);
tl.call(() => schaue(0, 0), null, 0);
loop(gsap.fromTo(trieb, { rotation: -4 }, { rotation: 4, duration: 1.3, ease: 'sine.inOut', yoyo: true, repeat: -1, delay: d }));
}
if (z === 'denkt') {
tl.call(() => schaue(.7, -.75, R), null, 0)
.to(mund, { attr: { d: MUND.o }, duration: d, ease: 'quint' }, 0)
.to(braueR, { opacity: 1, y: -7, rotation: -12, duration: d, ease: 'quint' }, 0)
.to(gedanke, { opacity: 1, duration: d }, 0)
.fromTo(gedanke.children, { scale: R ? 1 : 0 }, { scale: 1, duration: R ? 0 : .3, ease: 'quint', stagger: R ? 0 : .12 }, 0);
loop(gsap.to(gedanke.children, { opacity: .45, duration: .45, ease: 'sine.inOut', stagger: { each: .12, repeat: -1, yoyo: true }, delay: .5 }));
loop(gsap.fromTo(figur, { rotation: -2, svgOrigin: '130 216' }, { rotation: 2, duration: .6, ease: 'sine.inOut', yoyo: true, repeat: -1 }));
}
if (z === 'freut') {
tl.call(() => schaue(0, 0, true), null, 0)
.to(augen, { opacity: 0, duration: R ? 0 : .1 }, 0)
.to(froh, { opacity: 1, duration: R ? 0 : .1 }, 0)
.to(mund, { attr: { d: MUND.offen }, duration: d, ease: 'quint' }, 0)
.to(wangen, { opacity: .8, duration: d }, 0)
.to(bluete, { scale: 1, duration: R ? 0 : .5, ease: 'feder' }, R ? 0 : .5)
.to([blattL, blattR], { scale: 1.18, duration: R ? 0 : .5, ease: 'feder', transformOrigin: '100% 50%' }, R ? 0 : .45);
if (!R) {
tl.to(koerper, { scaleX: 1.08, scaleY: .9, duration: .12, ease: 'quint' }, 0)
.to(figur, { y: -34, duration: .32, ease: 'power2.out' }, .12)
.to(koerper, { scaleX: .96, scaleY: 1.06, duration: .32, ease: 'power2.out' }, .12)
.to(schatten, { scaleX: .7, opacity: .08, duration: .32, ease: 'power2.out' }, .12)
.to(figur, { y: 0, duration: .28, ease: 'power2.in' }, .44)
.to(schatten, { scaleX: 1, opacity: .16, duration: .28, ease: 'power2.in' }, .44)
.to(koerper, { scaleX: 1, scaleY: 1, duration: .5, ease: 'feder' }, .72)
.call(() => atmen(false), null, 1.22);
tl.fromTo(trieb, { rotation: -10 }, { rotation: 0, duration: .5, ease: 'feder' }, .72);
}
}
if (z === 'oops') {
tl.call(() => schaue(-.2, .8, R), null, 0)
.to(mund, { attr: { d: MUND.traurig }, duration: d, ease: 'quint' }, 0)
.to(braueL, { opacity: 1, rotation: -22, y: -2, duration: d, ease: 'quint' }, 0)
.to(braueR, { opacity: 1, rotation: 22, y: -2, duration: d, ease: 'quint' }, 0)
.to(trieb, { rotation: -24, duration: R ? 0 : .4, ease: 'quint' }, 0)
.to(wangen, { opacity: .2, duration: d }, 0);
if (!R) tl.to(figur, { x: 4, duration: .08, ease: 'sine.inOut', repeat: 5, yoyo: true }, .15).to(figur, { x: 0, duration: .08, ease: 'sine.inOut' });
}
if (z === 'schlaeft') {
tl.to(augen, { opacity: 0, duration: R ? 0 : .15 }, 0)
.to(zu, { opacity: 1, duration: R ? 0 : .15 }, 0)
.to(mund, { attr: { d: MUND.schlaf }, duration: d, ease: 'quint' }, 0)
.to(trieb, { rotation: -12, duration: R ? 0 : .6, ease: 'sine.inOut' }, 0)
.to(zzz, { opacity: 1, duration: 0 }, 0);
if (R) gsap.set(zzz.children, { opacity: 1 });
else loop(gsap.fromTo(zzz.children, { opacity: 0, y: 8 }, { keyframes: [{ opacity: 1, y: -6, duration: .8, ease: 'sine.inOut' }, { opacity: 0, y: -22, duration: 1.6, ease: 'sine.inOut' }], stagger: { each: .8, repeat: -1 } }));
}
}
function zeigeWerte() {
werte.innerHTML = reduziert()
? '<span>Reduziert: <b>statische Posen</b>, Zustandswechsel ohne Übergang, kein Atmen, kein Blinzeln, Blick bleibt mittig</span>'
: ZUSTAND[zustand].werte.map(t => `<span>${t}</span>`).join('');
}
/* Leerzustand: nach 25 s ohne Eingabe schläft Kalle, jede Bewegung weckt ihn */
let schlafUhr = 0;
function weckAuf() {
clearTimeout(schlafUhr);
if (zustand === 'schlaeft' && !html.classList.contains('vorfuehrung')) setze('ruhe');
schlafUhr = setTimeout(() => { if (zustand === 'ruhe' && !html.classList.contains('vorfuehrung')) setze('schlaeft'); }, 25000);
}
/* Quiz */
const quiz = $('[data-quiz]'), pruefen = $('[data-pruefen]'), rueck = $('[data-rueckmeldung]');
let pruefUhr = 0, geloest = false;
quiz.addEventListener('change', () => {
weckAuf();
$$('.antwort').forEach(a => a.classList.remove('falsch'));
pruefen.disabled = geloest; if (!geloest) { rueck.textContent = 'Bereit? Dann prüf deine Antwort.'; rueck.classList.remove('gut'); }
if (zustand === 'oops') setze('ruhe');
});
function pruefe() {
const w = quiz.antwort.value; if (!w || geloest) return;
pruefen.disabled = true; rueck.textContent = 'Wird geprüft …';
setze('denkt');
pruefUhr = setTimeout(() => {
const el = $(`[data-antwort="${w}"]`);
if (w === 'b') {
geloest = true; el.classList.add('richtig');
rueck.textContent = 'Richtig. Modul 03 geschafft.'; rueck.classList.add('gut');
const s = $('[data-segment]'); s.classList.remove('jetzt'); s.classList.add('geschafft');
$('[data-stand]').textContent = '3 von 8 Modulen geschafft';
setze('freut');
} else {
el.classList.add('falsch'); rueck.textContent = 'Noch nicht. Schau dir den Tipp von Kalle an.';
pruefen.disabled = false;
setze('oops');
}
}, 900);
}
quiz.addEventListener('submit', e => { e.preventDefault(); pruefe(); });
function quizZurueck() {
clearTimeout(pruefUhr); geloest = false;
quiz.reset(); pruefen.disabled = true; rueck.textContent = 'Wähle eine Antwort.'; rueck.classList.remove('gut');
$$('.antwort').forEach(a => a.classList.remove('falsch', 'richtig'));
const s = $('[data-segment]'); s.classList.remove('geschafft'); s.classList.add('jetzt');
$('[data-stand]').textContent = '2 von 8 Modulen geschafft';
}
/* Pult */
$$('[data-z]').forEach(b => b.addEventListener('click', () => { setze(b.dataset.z); weckAuf(); }));
const blickKnopf = $('[data-blick]');
blickKnopf.addEventListener('click', () => {
blickAn = !blickAn; blickKnopf.setAttribute('aria-pressed', String(blickAn));
blickKnopf.textContent = blickAn ? 'Folgt dem Zeiger' : 'Blick fest';
if (!blickAn && zustand === 'ruhe') schaue(0, 0);
});
$$('a[href^="#"]').forEach(a => a.addEventListener('click', e => e.preventDefault()));
let warReduziert = reduziert();
new MutationObserver(() => { if (reduziert() !== warReduziert) { warReduziert = reduziert(); setze(zustand, true); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
/* Vorführung: Zeiger fährt über die Antworten (Kalle schaut mit), falsche Antwort, Oops, richtige, Freude, Schlaf */
const zeiger = $('[data-zeiger]'), pos = { x: 0, y: 0 };
let uhren = [], laeuft = false, fahrt = null;
const spaeter = (fn, ms) => uhren.push(setTimeout(fn, ms));
function fahre(el, fx = .5, fy = .5, dauer = .7) {
const r = el.getBoundingClientRect();
if (fahrt) fahrt.kill();
fahrt = gsap.to(pos, { x: r.left + r.width * fx, y: r.top + r.height * fy, duration: dauer, ease: 'power2.inOut',
onUpdate: () => { zeiger.style.transform = `translate(${pos.x}px, ${pos.y}px)`; schaueAuf(pos.x, pos.y); } });
}
function drueck(fn, el) {
zeiger.classList.add('drueck'); el && el.classList.add('gedrueckt');
setTimeout(() => { zeiger.classList.remove('drueck'); el && el.classList.remove('gedrueckt'); fn(); }, 150);
}
function waehle(id) { $('#' + id).checked = true; quiz.dispatchEvent(new Event('change')); }
function runde() {
quizZurueck(); setze('ruhe');
const la = $('label[for="a-a"]'), lb = $('label[for="a-b"]'), lc = $('label[for="a-c"]');
let t = 0;
spaeter(() => fahre(lc, .3, .5, .5), t += 50);
spaeter(() => fahre(la, .35, .5, .6), t += 550);
spaeter(() => drueck(() => waehle('a-a')), t += 600);
spaeter(() => fahre(pruefen, .6, .6, .5), t += 200);
spaeter(() => drueck(() => pruefe(), pruefen), t += 550); // denkt, dann Oops
spaeter(() => fahre(lb, .4, .5, .7), t += 2400);
spaeter(() => drueck(() => waehle('a-b')), t += 750);
spaeter(() => fahre(pruefen, .6, .6, .5), t += 200);
spaeter(() => drueck(() => pruefe(), pruefen), t += 550); // denkt, dann Freude
spaeter(() => fahre($('[data-svg]'), .8, 1.05, .9), t += 1300);
spaeter(() => setze('schlaeft'), t += 2600);
spaeter(() => { runde(); }, t += 3600);
}
function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; if (fahrt) fahrt.pause(); gsap.globalTimeline.pause(); }
function vorfuehren() {
uhren.forEach(clearTimeout); uhren = []; gsap.globalTimeline.resume();
html.classList.add('vorfuehrung'); laeuft = true; clearTimeout(schlafUhr);
const r = $('label[for="a-c"]').getBoundingClientRect();
pos.x = r.left + 40; pos.y = r.bottom + 120; zeiger.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
runde();
}
function weiter() { gsap.globalTimeline.resume(); if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; runde(); } }
function neustart() {
uhren.forEach(clearTimeout); uhren = []; laeuft = false; gsap.globalTimeline.resume();
html.classList.remove('vorfuehrung'); quizZurueck(); setze('ruhe', true); weckAuf();
}
setze('ruhe', true); blinzeln(); weckAuf();
})();
</script>
</body>
</html>