Instanzierte Objektfelder
Ein Würfelfeld aus 1 904 Instanzen in einem Draw Call: Die Blöcke folgen dem Cursor als Welle, bilden die Stufen eines Lernwegs und zeichnen am Ende einen Haken.
Tausende gleiche Objekte (Würfel, Kugeln, Kacheln, Blätter) liegen als InstancedMesh in einem einzigen Draw Call. Jede Instanz hat eine eigene Matrix und Farbe. Bewegt wird im Vertex-Shader: Höhe, Farbe und Verzögerung hängen an instanceId bzw. an Instanz-Attributen, dazu Noise oder ein Compute-Pass. Das Feld weicht dem Cursor aus, wellt beim Scrollen oder formt Zeichen und Logos. Mit Physik wird es zum Spielzeug.
Masse und Ordnung ergeben hypnotische, architektonische Bilder. Wenn Hunderte Teile auf eine einzige Geste reagieren, fühlt sich das mächtig an. Formt das Feld ein Zeichen, wird aus Struktur eine Aussage.
Wann einsetzen
Hero-Hintergründe mit Idee, Daten-Metaphern (Mengen, Fortschritt, Stufen), Logo- und Zeichen-Formationen, Studio-Seiten.
Wann nicht
Wenn das Feld nur Hintergrundrauschen ohne Idee ist. Nicht hinter Fließtext. Nicht ohne Ersatz für Geräte ohne WebGL.
Varianten
- Würfelfeld mit Cursor-Welle
- Logo oder Zeichen aus Instanzen
- Daten-Metapher: Stufen oder Balken aus Instanzen
- Rotierende Texturen auf Geometrie
- Roaming Cubes mit Glas (Volatile Nexus)
Umsetzung
Ein InstancedMesh, Matrizen per setMatrixAt nur bei Änderung setzen (danach instanceMatrix.needsUpdate). Besser: Matrizen einmal setzen und die Animation in den Vertex-Shader legen, gesteuert über InstancedBufferAttribute und Uniforms (Zeit, Zeiger, Zustand). Stagger über einen Verzögerungswert je Instanz. Frustum Culling für Instanzen selbst rechnen oder mit frustumCulled = false abschalten. In React Three Fiber deklarativ mit drei Instances.
Robust umsetzen
Telefon. Instanzzahl reduzieren, Pixelverhältnis begrenzen. Tippen oder Wischen statt Hover.
Ohne Bewegung. Feld statisch. Zustandswechsel als kurze Überblendung.
Barrierefrei. Canvas dekorativ (aria-hidden). Was das Feld zeigt, steht zusätzlich als Text daneben. Bedienelemente sind echte Buttons.
Code
<!DOCTYPE html>
<!--
Instanzierte Objektfelder
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/instanzierte-objektfelder/
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>Instanzierte Objektfelder · 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 {
--grund: #e9edf5; --karte: #ffffff; --tinte: #141623; --leise: #5a6074; --linie: rgba(20, 22, 35, .1);
--marke: #4257f5; --marke-tief: #2f40c9; --erfolg: #10bf7a; --gelb: #f7c325;
--kurve: cubic-bezier(0.22, 1, 0.36, 1); /* easeOutQuint, Grundkurve des Rezepts */
--feder: cubic-bezier(0.34, 1.56, 0.64, 1); /* nur für Erfolgsmomente */
--rand: clamp(20px, 4vw, 60px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Onest', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; background: var(--grund); }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.hand { font-family: 'Shantell Sans', 'Onest', sans-serif; font-variation-settings: 'INFM' 40, 'BNCE' 0; font-weight: 500; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
a { color: inherit; }
/* ---------- Bühne ---------- */
.buehne { position: relative; height: 100svh; min-height: 700px; overflow: hidden; isolation: isolate; touch-action: pan-y; }
.feld { position: absolute; inset: 0; z-index: 0; }
.feld canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.feld [data-canvas] { opacity: 0; transition: opacity .6s var(--kurve); }
.gl-bereit .feld [data-canvas] { opacity: 1; }
.feld [data-canvas].blende { opacity: 0; transition-duration: 150ms; }
.ersatz { display: none; }
.ohne-webgl .ersatz { display: block; }
.schleier { position: absolute; inset: 0; z-index: 1; pointer-events: none;
background: linear-gradient(90deg, var(--grund) 0%, rgba(233, 237, 245, .94) 30%, rgba(233, 237, 245, 0) 52%),
linear-gradient(180deg, var(--grund) 0%, rgba(233, 237, 245, 0) 16%, rgba(233, 237, 245, 0) 82%, rgba(233, 237, 245, .9) 100%); }
.kopf { position: absolute; z-index: 3; inset: 0 0 auto; display: flex; align-items: center; gap: 36px; padding: 22px var(--rand); }
.marke { display: flex; align-items: center; gap: 10px; font: 700 21px/1 'Bricolage Grotesque', sans-serif; letter-spacing: -.02em; text-decoration: none; }
.marke svg { width: 26px; height: 26px; }
.marke small { font: 400 13px/1 'Onest', sans-serif; letter-spacing: 0; color: var(--leise); margin-left: 4px; }
.nav { display: flex; gap: 26px; margin-left: auto; font-size: 15px; }
.nav a { text-decoration: none; color: var(--leise); transition: color .15s linear; }
.nav a:hover, .nav a:focus-visible { color: var(--tinte); }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 20px; border-radius: 999px; font: 600 15px/1 'Onest', sans-serif; text-decoration: none; border: 0; cursor: pointer;
transition: transform .15s var(--kurve), background-color .15s linear, box-shadow .15s linear; }
.knopf:active { transform: scale(.97); }
.knopf:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
.knopf.dunkel { background: var(--tinte); color: #fff; height: 40px; padding: 0 18px; font-size: 14px; }
.knopf.voll { background: var(--marke); color: #fff; box-shadow: 0 6px 18px -8px rgba(66, 87, 245, .8); }
.knopf.voll:hover { background: var(--marke-tief); }
.knopf.hell { background: rgba(255, 255, 255, .7); color: var(--tinte); box-shadow: inset 0 0 0 1px var(--linie); }
.knopf.hell:hover { background: #fff; }
.text { position: absolute; z-index: 2; left: var(--rand); top: 50%; translate: 0 -47%; width: min(620px, 45vw); }
.start { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 24px; }
.plakette { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border-radius: 999px; background: var(--gelb); font: 600 13px/1 'Onest', sans-serif; font-variant-numeric: tabular-nums; }
.plakette::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--tinte); }
.start .hand { font-size: 15px; color: var(--leise); rotate: -2deg; }
h1 { font: 700 clamp(2.25rem, 1.2rem + 3vw, 4rem)/1.05 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 96; letter-spacing: -.02em; text-wrap: balance; }
h1 mark { position: relative; color: inherit; background: none; white-space: nowrap; }
h1 mark::before { content: ''; position: absolute; z-index: -1; left: -.06em; right: -.08em; bottom: .06em; height: .36em; border-radius: .12em; background: var(--gelb);
transform: scaleX(0); transform-origin: left; transition: transform .6s var(--kurve) .35s; }
.gl-bereit h1 mark::before, .ohne-webgl h1 mark::before, .reduziert h1 mark::before { transform: scaleX(1); }
.unter { margin-top: 22px; max-width: 46ch; font-size: 1.0625rem; line-height: 1.6; color: var(--leise); text-wrap: pretty; }
.preis { display: flex; align-items: baseline; gap: 6px 16px; flex-wrap: wrap; margin-top: 26px; font-variant-numeric: tabular-nums; }
.preis b { font: 700 32px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 48; letter-spacing: -.02em; }
.preis span { font-size: 15px; color: var(--leise); }
.preis .fein { flex-basis: 100%; font-size: 13.5px; }
.preis .fein::before { content: '✓'; color: var(--erfolg); font-weight: 700; margin-right: 6px; }
.aktionen { display: flex; gap: 10px; margin-top: 26px; flex-wrap: wrap; }
/* Stand-Karte: was das Feld gerade zeigt */
.stand { position: absolute; z-index: 2; right: var(--rand); top: 112px; width: 268px; padding: 16px 18px 17px; border-radius: 16px; background: rgba(255, 255, 255, .82); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 1px 0 rgba(255, 255, 255, .9) inset, 0 18px 40px -24px rgba(20, 22, 35, .45); }
.stand .hand { font-size: 14px; color: var(--marke); }
.stand h2 { text-wrap: balance; font: 650 19px/1.25 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 24; letter-spacing: -.01em; margin-top: 4px; }
.stand p { font-size: 13.5px; line-height: 1.5; color: var(--leise); margin-top: 6px; }
.balken { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; margin-top: 12px; }
.balken i { height: 6px; border-radius: 3px; background: rgba(20, 22, 35, .1); transition: background-color .3s var(--kurve); }
.balken i.fertig { background: var(--erfolg); animation: plopp .5s var(--feder); }
.balken i.jetzt { background: var(--marke); }
@keyframes plopp { 0% { transform: scaleY(1); } 40% { transform: scaleY(1.9); } 100% { transform: scaleY(1); } }
.stand .knopf { height: 36px; padding: 0 14px; font-size: 13.5px; margin-top: 14px; }
.stand .knopf[hidden] { display: none; }
.steuer { position: absolute; z-index: 3; right: calc(var(--rand) - 18px); bottom: 26px; padding: 18px 18px 0 0; display: grid; justify-items: end; gap: 10px; }
.modi { display: flex; gap: 6px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(255, 255, 255, .6); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear, color .15s linear, background-color .15s linear; }
.modi button:hover { color: var(--tinte); border-color: rgba(20, 22, 35, .3); }
.modi button:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
.modi button[aria-pressed='true'] { background: var(--tinte); color: #fff; border-color: var(--tinte); }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 3px 14px; max-width: 600px; text-align: right; }
.werte b { color: var(--tinte); font-weight: 500; }
.zeiger { position: absolute; z-index: 4; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border: 1.5px solid var(--tinte); border-radius: 50%; background: rgba(255, 255, 255, .35); pointer-events: none; display: none; }
.zeiger.an { display: block; }
/* ---------- Unter der Bühne: der Lernweg ---------- */
.lernweg { padding: clamp(64px, 9vw, 120px) var(--rand); background: #fff; }
.lernweg-kopf { display: flex; justify-content: space-between; align-items: end; gap: 24px; flex-wrap: wrap; margin-bottom: clamp(32px, 4vw, 52px); }
.lernweg h2 { font: 650 clamp(1.75rem, 1.2rem + 2vw, 2.75rem)/1.1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 72; letter-spacing: -.02em; max-width: 18ch; }
.lernweg-kopf p { max-width: 42ch; color: var(--leise); line-height: 1.6; }
.module { list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.module li { padding: 22px 22px 24px; border-radius: 16px; background: var(--grund); }
.module .hand { font-size: 14.5px; color: var(--marke); }
.module h3 { font: 650 21px/1.2 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 32; letter-spacing: -.01em; margin: 8px 0 8px; }
.module p { font-size: 15px; line-height: 1.55; color: var(--leise); }
.module .ergebnis { margin-top: 12px; color: var(--tinte); font-size: 14.5px; }
.module .ergebnis::before { content: '✓'; color: var(--erfolg); font-weight: 700; margin-right: 7px; }
.vorschau body { overflow: hidden; }
.vorschau .lernweg, .vorschau .nav, .vorschau .unter, .vorschau .preis .fein, .vorschau .aktionen .hell { display: none; }
.vorschau .buehne { height: 100vh; min-height: 0; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; transition-delay: 0s !important; }
@media (max-width: 1100px) {
.nav { display: none; }
.kopf .knopf { margin-left: auto; }
.stand { width: 236px; }
}
@media (max-height: 780px) and (min-width: 761px) { .unter { display: none; } .stand p { display: none; } }
@media (max-width: 760px) {
.buehne { height: auto; min-height: 0; display: flex; flex-direction: column; }
.kopf { position: relative; padding: 16px var(--rand); }
.marke small { display: none; }
.feld { position: relative; inset: auto; height: 50svh; min-height: 330px; order: 1; }
.schleier { background: linear-gradient(180deg, var(--grund) 0%, rgba(233, 237, 245, 0) 14%, rgba(233, 237, 245, 0) 78%, var(--grund) 100%); }
.text { position: relative; inset: auto; translate: none; width: auto; order: 2; padding: 0 var(--rand) 32px; margin-top: -8px; }
.start { margin-bottom: 16px; }
h1 { font-size: clamp(2.1rem, 9.4vw, 2.6rem); }
.unter { font-size: 1rem; margin-top: 16px; }
.stand { top: auto; bottom: 12px; left: var(--rand); right: auto; width: auto; max-width: calc(100% - 2 * var(--rand)); padding: 10px 14px 12px; }
.stand p, .stand .knopf { display: none; }
.stand h2 { font-size: 16px; }
.balken { margin-top: 8px; }
.steuer { position: relative; inset: auto; order: 3; justify-items: start; padding: 0 var(--rand) 36px; }
.werte { justify-content: flex-start; text-align: left; }
.module { grid-template-columns: 1fr; }
}
@media (hover: none) {
/* Touch: Tippen oder Wischen löst die Welle an der Stelle aus */
.stand .tipp-maus { display: none; }
}
@media (hover: hover) { .stand .tipp-touch { display: none; } }
</style>
</head>
<body>
<section class="buehne" data-buehne aria-labelledby="titel">
<div class="feld" data-feld>
<canvas class="ersatz" data-ersatz aria-hidden="true"></canvas>
<canvas data-canvas aria-hidden="true"></canvas>
<div class="schleier" aria-hidden="true"></div>
<aside class="stand" data-stand aria-live="polite">
<p class="hand" data-stand-label>Probier’s aus</p>
<h2 data-stand-titel>Sechs Module, 48 Übungen</h2>
<p data-stand-text><span class="tipp-maus">Fahr mit der Maus über das Feld.</span><span class="tipp-touch">Tipp oder wisch über das Feld.</span> Jeder Block reagiert für sich.</p>
<div class="balken" data-balken aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></div>
<button class="knopf voll" type="button" data-abhaken hidden>Modul 05 abhaken</button>
</aside>
</div>
<header class="kopf">
<a class="marke" href="#titel" aria-label="Stufenwerk, Akademie für Datenkompetenz">
<svg viewBox="0 0 26 26" aria-hidden="true"><rect x="1" y="15" width="7" height="10" rx="1.5" fill="#4257f5"/><rect x="9.5" y="9" width="7" height="16" rx="1.5" fill="#4257f5"/><rect x="18" y="2" width="7" height="23" rx="1.5" fill="#10bf7a"/></svg>
Stufenwerk<small>Halle (Saale)</small>
</a>
<nav class="nav" aria-label="Hauptnavigation">
<a href="#lernweg">Lernweg</a><a href="#lernweg">Trainerin</a><a href="#lernweg">Preise</a><a href="#lernweg">Fragen</a>
</nav>
<a class="knopf dunkel" href="#lernweg">Platz sichern</a>
</header>
<div class="text">
<div class="start"><span class="plakette">Nächster Start 12.01.2027</span><span class="hand">live aus Halle, auch online</span></div>
<h1 id="titel">In sechs Wochen vom Zahlensalat zum <mark>Monatsbericht</mark>.</h1>
<p class="unter">Der Live-Kurs für alle, die im Büro, im Verein oder im eigenen Laden mit Tabellen arbeiten. Dienstags um 18 Uhr, 90 Minuten, mit Aufzeichnung und Übungsdateien.</p>
<p class="preis"><b>390 €</b><span>oder 3 × 135 €</span><span class="fein">Bildungsgutschein möglich · 14 Tage Rückgaberecht</span></p>
<div class="aktionen"><a class="knopf voll" href="#lernweg">Platz sichern</a><a class="knopf hell" href="#lernweg">Lernweg ansehen</a></div>
</div>
<div class="steuer">
<div class="modi mono" role="group" aria-label="Variante wählen" data-modi>
<button type="button" data-modus="welle" aria-pressed="true">Welle</button>
<button type="button" data-modus="lernweg" aria-pressed="false">Lernweg</button>
<button type="button" data-modus="formation" aria-pressed="false">Formation</button>
</div>
<p class="werte mono" data-werte></p>
</div>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
</section>
<section class="lernweg" id="lernweg" aria-labelledby="lernweg-titel">
<div class="lernweg-kopf">
<h2 id="lernweg-titel">Eine Stufe pro Woche. Am Ende steht dein Bericht.</h2>
<p>Jedes Modul endet mit einer Übung an deinen eigenen Zahlen. Wer eine Woche verpasst, schaut die Aufzeichnung und fragt im Forum nach.</p>
</div>
<ol class="module">
<li><p class="hand">Modul 01 · 45 min</p><h3>Daten sauber machen</h3><p>Doppelte Zeilen, falsche Datumsformate, verbundene Zellen: aufräumen in zehn Minuten statt zwei Stunden.</p><p class="ergebnis">Eine saubere Ausgangstabelle</p></li>
<li><p class="hand">Modul 02 · 50 min</p><h3>Formeln ohne Angst</h3><p>WENN, SUMMEWENNS und relative Bezüge. Mit Spickzettel zum Ausdrucken.</p><p class="ergebnis">Deine erste Auswertung</p></li>
<li><p class="hand">Modul 03 · 55 min</p><h3>Tabellen verknüpfen</h3><p>XVERWEIS statt Abtippen. Zwei Listen zusammenführen, ohne dass etwas verloren geht.</p><p class="ergebnis">Kunden- und Umsatzliste in einer</p></li>
<li><p class="hand">Modul 04 · 60 min</p><h3>Pivot-Tabellen</h3><p>Von 4 000 Zeilen zur Monatsübersicht mit drei Klicks. Filtern, gruppieren, vergleichen.</p><p class="ergebnis">Umsatz nach Monat und Filiale</p></li>
<li><p class="hand">Modul 05 · 50 min</p><h3>Diagramme, die man versteht</h3><p>Weniger Farbe, klare Beschriftung, eine Aussage pro Diagramm.</p><p class="ergebnis">Drei Diagramme für den Bericht</p></li>
<li><p class="hand">Modul 06 · 70 min</p><h3>Dein Monatsbericht</h3><p>Alles zusammen auf einer Seite, die sich jeden Monat fast von selbst aktualisiert.</p><p class="ergebnis">Bericht und Zertifikat</p></li>
</ol>
</section>
<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 beruehrung = matchMedia('(hover: none)').matches;
const schmal = () => innerWidth <= 760;
const buehne = document.querySelector('[data-buehne]');
const feld = document.querySelector('[data-feld]');
const canvas = document.querySelector('[data-canvas]');
const ersatz = document.querySelector('[data-ersatz]');
const zeiger = document.querySelector('[data-zeiger]');
const werteEl = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];
const st = { label: document.querySelector('[data-stand-label]'), titel: document.querySelector('[data-stand-titel]'), text: document.querySelector('[data-stand-text]'),
balken: [...document.querySelectorAll('[data-balken] i')], abhaken: document.querySelector('[data-abhaken]') };
const textWelle = st.text.innerHTML;
/* ---------- Werte (meta.json → messwerte: 1k–100k Instanzen, Stagger über instanceId-Offset;
Kurven aus dem Rezept kurs-coaching-lernweg) ---------- */
const W = {
raster: { desktop: [56, 34], mobil: [30, 22] }, // 1 904 bzw. 660 Instanzen, ein Draw Call
wuerfel: 0.84, // Kantenlänge je Zelle (Zelle = 1), Fuge 0,16
kurve: [0.22, 1, 0.36, 1], // easeOutQuint je Instanz
dauerMs: 600, // Dauer je Instanz beim Variantenwechsel
sweepMs: 720, // Versatz über das Feld (Spalte × 0,6 + Zeile × 0,4) …
jitterMs: 80, // … plus Zufall je Instanz
feder: [0.34, 1.56, 0.64, 1], // Überschwinger nur für Erfolg (Modul fertig, Haken)
federMs: 560,
strichMs: 600, // Haken „zeichnet“ sich: Versatz entlang des Strichs
bandMs: 240, // Versatz innerhalb einer Stufe beim Abhaken
taktMs: 420, // Abhak-Takt beim Betreten des Lernwegs
hub: 2.4, // Cursor-Welle: Anhebung in Zellen
radius: 4.5, // Gauß-Radius in Zellen
kraeuseln: 0.35, // Ringwelle um den Cursor
lerp: 7, // Zeiger-Nachlauf 1/s
ruhe: 0.3, // Grundwelle des Feldes (Amplitude)
treppe: { basis: 0.3, stufe: 0.42, bonus: 0.25 },
haken: { hoehe: 2.1, flach: 0.12 },
kamera: { fov: 26, azimut: 40, neigung: 36 },
blendeMs: [150, 250],
};
const FARBE = { grund: '#e9edf5', weiss: '#fbfcfe', marke: '#4257f5', erfolg: '#10bf7a', leer: '#d3d9e6' };
const MODI = ['welle', 'lernweg', 'formation'];
const ZAHL = n => n.toLocaleString('de-DE').replace(/\./g, ' ');
const kb = k => `cubic-bezier(${k.join(', ')})`;
let modus = 'welle', fertig = 0;
const istMobil = beruehrung || schmal();
const [SP, ZE] = istMobil ? W.raster.mobil : W.raster.desktop;
const N = SP * ZE;
/* Stufen des Lernwegs: 6 Bänder à 4 Zeilen (mobil 2) mit je einer Wegzeile dazwischen,
sie steigen nach hinten rechts an (−z) */
const BAND_B = istMobil ? 2 : 4, BAND_START = istMobil ? 2 : 2;
function bandVon(ze) {
const r = (ZE - 1 - ze) - BAND_START; if (r < 0) return -1;
const b = Math.floor(r / (BAND_B + 1)), innen = r % (BAND_B + 1);
if (b > 5) return -1; return innen === BAND_B ? -2 : b; // -2 = Weg, -1 = außerhalb
}
/* Zufall mit Startwert, damit jede Vorschau gleich aussieht */
const zufall = (s => () => (s = (s * 16807) % 2147483647) / 2147483647)(11);
const daten = new Float32Array(N * 4), zelle = new Float32Array(N * 2);
for (let i = 0; i < N; i++) {
const sp = i % SP, ze = Math.floor(i / SP), z = zufall();
zelle[i * 2] = sp - (SP - 1) / 2; zelle[i * 2 + 1] = ze - (ZE - 1) / 2;
const verz = ((sp / SP) * 0.6 + (ze / ZE) * 0.4) * W.sweepMs / 1000 + z * W.jitterMs / 1000;
daten.set([bandVon(ze), -1, z, verz], i * 4); // Haken-Wert folgt im Layout
}
function zeigeWerte(info) {
let s;
if (reduziert()) s = `<span>instanzen <b>${ZAHL(N)}</b></span><span>draw calls <b>${info ?? 1}</b></span><span>bewegung <b>aus</b></span><span>wechsel <b>blende ${W.blendeMs[0]} / ${W.blendeMs[1]} ms</b></span>`;
else if (modus === 'welle') s = `<span>instanzen <b>${ZAHL(N)}</b></span>${html.classList.contains('ohne-webgl') ? '<span>ersatz <b>canvas 2d, statisch</b></span>' : `<span>draw calls <b>${info ?? 1}</b></span>`}<span>hub <b>${String(W.hub).replace('.', ',')} zellen</b></span><span>radius <b>${String(W.radius).replace('.', ',')}</b></span><span>nachlauf <b>${W.lerp}/s</b></span>`;
else if (modus === 'lernweg') s = `<span>kurve <b>${kb(W.kurve)}</b></span><span>je block <b>${W.dauerMs} ms</b></span><span>stagger <b>${W.sweepMs} ms über instanceId</b></span><span>abhaken <b>feder ${W.federMs} ms</b></span>`;
else s = `<span>feder <b>${kb(W.feder)}</b></span><span>je block <b>${W.federMs} ms</b></span><span>strich <b>${W.strichMs} ms</b></span><span>im haken <b>${ZAHL(hakenZahl)}</b></span>`;
werteEl.innerHTML = s;
}
let hakenZahl = 0;
const MODUL = ['Daten sauber machen', 'Formeln ohne Angst', 'Tabellen verknüpfen', 'Pivot-Tabellen', 'Diagramme, die man versteht', 'Dein Monatsbericht'];
function zeigeStand() {
st.balken.forEach((b, i) => {
const war = b.classList.contains('fertig');
const f = (modus === 'lernweg' && i < fertig) || modus === 'formation';
b.classList.toggle('fertig', f); b.classList.toggle('jetzt', modus === 'lernweg' && i === fertig);
if (f && !war) { b.style.animation = 'none'; b.offsetWidth; b.style.animation = ''; }
});
st.abhaken.hidden = modus !== 'lernweg' || vorfuehrend;
if (modus === 'welle') { st.label.textContent = 'Probier’s aus'; st.titel.textContent = 'Sechs Module, 48 Übungen'; st.text.innerHTML = textWelle; }
else if (modus === 'lernweg') {
st.label.textContent = fertig < 6 ? `Modul ${String(fertig + 1).padStart(2, '0')} von 06` : 'Alle Module erledigt';
st.titel.textContent = fertig < 6 ? MODUL[fertig] : 'Bereit für den Abschluss';
st.text.textContent = `Dein Stand: ${fertig} von 6 Stufen geschafft. Grün ist erledigt, Blau ist diese Woche dran.`;
st.abhaken.textContent = fertig < 6 ? `Modul ${String(fertig + 1).padStart(2, '0')} abhaken` : 'Zum Abschluss';
} else { st.label.textContent = 'Geschafft!'; st.titel.textContent = 'Kurs abgeschlossen'; st.text.textContent = 'Bericht abgegeben, Zertifikat kommt per Post. Die Blöcke formen den Haken.'; }
}
/* ---------- Renderer ---------- */
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
const api = renderer ? mitWebGL() : ohneWebGL();
/* ---------- Bedienung ---------- */
let schleife = [], vorfuehrend = false, abhakTimer = [];
function stoppeAbhaken() { abhakTimer.forEach(clearTimeout); abhakTimer = []; }
function stoppeVorfuehrung() { schleife.forEach(clearTimeout); schleife = []; vorfuehrend = false; html.classList.remove('vorfuehrung'); api.virtuell(null); zeigeStand(); }
function setzeModus(m, ohneAuto) {
stoppeAbhaken();
modus = m; fertig = 0;
knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === m)));
api.modus(MODI.indexOf(m));
zeigeWerte(); zeigeStand();
if (m === 'lernweg' && !ohneAuto) {
// beim Betreten: Module 01–04 nacheinander abhaken, Modul 05 ist „diese Woche“
const t0 = reduziert() ? 0 : W.dauerMs + W.sweepMs * 0.6;
for (let k = 0; k < 4; k++) abhakTimer.push(setTimeout(abhaken, reduziert() ? 0 : t0 + k * W.taktMs));
}
}
function abhaken() {
if (modus !== 'lernweg') return;
if (fertig >= 6) { setzeModus('formation'); return; }
api.fertig(fertig); fertig++; zeigeStand();
}
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); }));
st.abhaken.addEventListener('click', () => { stoppeVorfuehrung(); stoppeAbhaken(); abhaken(); });
buehne.addEventListener('pointerdown', e => { if (e.isTrusted && vorfuehrend && !e.target.closest('a, button')) stoppeVorfuehrung(); });
function neustart() { stoppeVorfuehrung(); setzeModus('welle'); api.neustart(); }
function vorfuehren() {
stoppeVorfuehrung();
vorfuehrend = true; html.classList.add('vorfuehrung');
const takt = () => {
// 1) Welle: gedachter Zeiger zieht eine Schleife über das Feld
setzeModus('welle'); api.virtuell(true);
// 2) Lernweg: alle sechs Stufen werden nacheinander abgehakt
schleife.push(setTimeout(() => { api.virtuell(null); setzeModus('lernweg', true); }, 5600));
for (let k = 0; k < 6; k++) schleife.push(setTimeout(abhaken, 5600 + W.dauerMs + W.sweepMs * 0.6 + k * 520));
// 3) Formation: der Haken als Erfolgsmoment
schleife.push(setTimeout(() => setzeModus('formation'), 10400));
schleife.push(setTimeout(takt, 14600));
};
takt();
}
function pause() { schleife.forEach(clearTimeout); schleife = []; stoppeAbhaken(); api.pause(true); }
function weiter() { api.pause(false); vorfuehren(); }
zeigeWerte(); zeigeStand();
addEventListener('load', () => { if (!html.classList.contains('vorschau')) api.neustart(); });
/* ======================================================================
WebGL: ein InstancedMesh, Matrizen einmal gesetzt (nur x/z),
Höhe, Farbe und Staffelung rechnet der Vertex-Shader je instanceId
====================================================================== */
function mitWebGL() {
renderer.setPixelRatio(Math.min(devicePixelRatio, istMobil ? 1.5 : 2));
const C = k => new THREE.Color(FARBE[k]);
renderer.setClearColor(C('grund'), 1);
const szene = new THREE.Scene();
const kamera = new THREE.PerspectiveCamera(W.kamera.fov, 1, 1, 400);
const geo = new THREE.BoxGeometry(W.wuerfel, 1, W.wuerfel);
geo.translate(0, 0.5, 0); // Boden bei y = 0, Höhe skaliert der Shader
geo.setAttribute('aZelle', new THREE.InstancedBufferAttribute(zelle, 2));
const datenAttr = new THREE.InstancedBufferAttribute(daten, 4);
geo.setAttribute('aDaten', datenAttr);
const u = {
uZeit: { value: 0 }, uVon: { value: 3 }, uNach: { value: 0 }, uWechsel: { value: 0 },
uMaus: { value: new THREE.Vector2(0, 0) }, uMausAn: { value: 0 },
uFertigZahl: { value: 0 }, uFertig: { value: new Array(6).fill(-100) },
uKurve: { value: new THREE.Vector4(...W.kurve) }, uFeder: { value: new THREE.Vector4(...W.feder) },
uDauer: { value: W.dauerMs / 1000 }, uFederDauer: { value: W.federMs / 1000 }, uStrich: { value: W.strichMs / 1000 }, uBand: { value: W.bandMs / 1000 },
uHub: { value: W.hub }, uRadius: { value: W.radius }, uKraus: { value: W.kraeuseln }, uRuhe: { value: W.ruhe },
uTreppe: { value: new THREE.Vector3(W.treppe.basis, W.treppe.stufe, W.treppe.bonus) }, uHaken: { value: new THREE.Vector2(W.haken.hoehe, W.haken.flach) },
uWeiss: { value: C('weiss') }, uMarke: { value: C('marke') }, uErfolg: { value: C('erfolg') }, uLeer: { value: C('leer') }, uGrund: { value: C('grund') },
uNebel: { value: new THREE.Vector2(60, 120) },
};
const mat = new THREE.ShaderMaterial({
uniforms: u,
vertexShader: `
attribute vec2 aZelle; attribute vec4 aDaten; // band, haken (−1 / 0…1), zufall, verzögerung
uniform float uZeit, uVon, uNach, uWechsel, uMausAn, uFertigZahl, uFertig[6];
uniform vec2 uMaus, uHaken; uniform vec3 uTreppe;
uniform vec4 uKurve, uFeder;
uniform float uDauer, uFederDauer, uStrich, uBand, uHub, uRadius, uKraus, uRuhe;
uniform vec3 uWeiss, uMarke, uErfolg, uLeer;
varying vec3 vFarbe, vN; varying float vY, vH, vTiefe; varying vec2 vUv;
// CSS-cubic-bezier: x(t) per Newton auflösen, dann y(t)
float bz(float a, float b, float t) { float m = 1.0 - t; return 3.0 * m * m * t * a + 3.0 * m * t * t * b + t * t * t; }
float bzD(float a, float b, float t) { float m = 1.0 - t; return 3.0 * m * m * a + 6.0 * m * t * (b - a) + 3.0 * t * t * (1.0 - b); }
float kurve(vec4 k, float x) {
x = clamp(x, 0.0, 1.0); float t = x;
for (int i = 0; i < 6; i++) { float d = bzD(k.x, k.z, t); if (abs(d) > 1e-4) t -= (bz(k.x, k.z, t) - x) / d; t = clamp(t, 0.0, 1.0); }
return bz(k.y, k.w, t);
}
float fertigZeit(float b) {
float z = -100.0;
for (int i = 0; i < 6; i++) if (abs(float(i) - b) < 0.5) z = uFertig[i];
return z;
}
void zustand(float m, out float h, out vec3 f) {
float band = aDaten.x, zuf = aDaten.z;
if (m < 0.5) { // Welle: ruhige Grundwelle
h = 0.36 + uRuhe * sin(aZelle.x * 0.32 + uZeit * 0.8) * cos(aZelle.y * 0.27 - uZeit * 0.6) + 0.08 * zuf;
f = uWeiss;
} else if (m < 1.5) { // Lernweg: sechs Stufen
if (band < -0.5) { h = band < -1.5 ? 0.1 : 0.16 + 0.05 * zuf; f = band < -1.5 ? uLeer : uWeiss; }
else {
h = uTreppe.x + band * uTreppe.y + 0.03 * sin(uZeit * 1.2 + zuf * 6.283);
f = uWeiss;
if (band < uFertigZahl - 0.5) {
float p = (uZeit - fertigZeit(band) - (aZelle.x / 60.0 + 0.5) * uBand - zuf * 0.04) / uFederDauer;
float k = kurve(uFeder, p);
h += uTreppe.z * k; f = mix(uWeiss, uErfolg, clamp(k, 0.0, 1.0));
} else if (abs(band - uFertigZahl) < 0.5) f = mix(uWeiss, uMarke, 0.78 + 0.22 * sin(uZeit * 3.2));
}
} else if (m < 2.5) { // Formation: Haken aus Instanzen
if (aDaten.y >= 0.0) { h = uHaken.x; f = uErfolg; }
else { h = uHaken.y + 0.04 * zuf; f = uWeiss; }
} else { h = 0.04; f = uWeiss; } // flach (Start)
}
void main() {
float hV, hN; vec3 fV, fN;
zustand(uVon, hV, fV); zustand(uNach, hN, fN);
bool feder = uNach > 1.5 && uNach < 2.5 && aDaten.y >= 0.0;
float d = feder ? 0.18 + aDaten.y * uStrich : aDaten.w;
float p = (uZeit - uWechsel - d) / (feder ? uFederDauer : uDauer);
float c = feder ? kurve(uFeder, p) : kurve(uKurve, p);
float h = mix(hV, hN, c);
vec3 f = mix(fV, fN, clamp(c, 0.0, 1.0));
// Cursor-Welle: Gauß-Hügel plus Ringwelle, in der Welle voll, sonst halb
float dist = distance(aZelle, uMaus);
float hub = uMausAn * (uHub * exp(-dist * dist / (uRadius * uRadius)) + uKraus * sin(dist * 1.1 - uZeit * 5.0) * exp(-dist * 0.2));
float anteil = mix(1.0, 0.45, step(0.5, uNach));
hub *= anteil;
h = max(0.04, h + hub);
f = mix(f, uMarke, smoothstep(0.35, 2.2, hub) * (1.0 - step(0.5, uNach) * 0.6));
vec3 p3 = position; p3.y *= h;
vec4 welt = modelMatrix * instanceMatrix * vec4(p3, 1.0);
vec4 mv = viewMatrix * welt;
gl_Position = projectionMatrix * mv;
vFarbe = f; vN = normal; vY = p3.y; vH = h; vTiefe = -mv.z; vUv = uv;
}`,
fragmentShader: `
uniform vec3 uGrund; uniform vec2 uNebel;
varying vec3 vFarbe, vN; varying float vY, vH, vTiefe; varying vec2 vUv;
void main() {
vec3 n = normalize(vN), c;
if (n.y > 0.5) {
float kante = min(min(vUv.x, 1.0 - vUv.x), min(vUv.y, 1.0 - vUv.y));
c = vFarbe * mix(0.94, 1.0, smoothstep(0.15, 1.6, vH)) * mix(0.93, 1.0, smoothstep(0.0, 0.05, kante));
} else {
float seite = n.x > 0.5 ? 0.86 : (n.z > 0.5 ? 0.72 : 0.8);
float ao = mix(0.6, 1.0, smoothstep(0.0, 0.9, vY));
c = vFarbe * seite * ao * vec3(0.93, 0.95, 1.0);
}
c = mix(c, uGrund, smoothstep(uNebel.x, uNebel.y, vTiefe));
gl_FragColor = vec4(c, 1.0);
#include <colorspace_fragment>
}`,
});
const feldMesh = new THREE.InstancedMesh(geo, mat, N);
const m4 = new THREE.Matrix4();
for (let i = 0; i < N; i++) { m4.makeTranslation(zelle[i * 2], 0, zelle[i * 2 + 1]); feldMesh.setMatrixAt(i, m4); }
feldMesh.instanceMatrix.needsUpdate = true;
feldMesh.frustumCulled = false; // Instanzen liegen außerhalb der Basis-Box
szene.add(feldMesh);
// Kamera auf Kugelkoordinaten, Feld per View-Offset nach rechts geschoben
let b = 1, h = 1;
const az = THREE.MathUtils.degToRad(W.kamera.azimut), ne = THREE.MathUtils.degToRad(W.kamera.neigung);
function layout() {
b = feld.clientWidth; h = feld.clientHeight;
renderer.setSize(b, h, false);
kamera.aspect = b / h;
const mob = schmal();
const abstand = mob ? 44 / Math.min(1, kamera.aspect * 1.25) : 66 * Math.max(1, 1.45 / kamera.aspect);
kamera.position.set(Math.sin(az) * Math.cos(ne) * abstand, Math.sin(ne) * abstand, Math.cos(az) * Math.cos(ne) * abstand);
kamera.lookAt(0, 0.6, 0);
if (mob) kamera.setViewOffset(b, h, 0, h * 0.07, b, h); else kamera.setViewOffset(b, h, -b * 0.17, 0, b, h);
kamera.updateProjectionMatrix();
u.uNebel.value.set(abstand * 1.05, abstand * 1.9);
hakenLayout(mob);
}
// Haken im Bildraum definieren und auf die Zellen zurückprojizieren (Spitzen bilden das Zeichen)
function hakenLayout(mob) {
kamera.updateMatrixWorld();
const ax = mob ? b * 0.5 : b * 0.66, ay = mob ? h * 0.42 : h * 0.52, S = mob ? Math.min(b * 0.66, h * 0.62) : Math.min(b * 0.3, h * 0.48);
const P = [[-0.44, -0.02], [-0.13, 0.27], [0.46, -0.34]].map(([x, y]) => [ax + x * S, ay + y * S]);
const l1 = Math.hypot(P[1][0] - P[0][0], P[1][1] - P[0][1]), l2 = Math.hypot(P[2][0] - P[1][0], P[2][1] - P[1][1]), L = l1 + l2;
const v = new THREE.Vector3(), v2 = new THREE.Vector3();
v.set(0, W.haken.hoehe, 0).project(kamera); v2.set(1, W.haken.hoehe, 0).project(kamera);
const zellPx = Math.hypot((v2.x - v.x) * b / 2, (v2.y - v.y) * h / 2);
const dicke = Math.max(S * (mob ? 0.075 : 0.062), zellPx * 1.3); // mindestens gut eine Zelle breit, sonst reißt der Strich
hakenZahl = 0;
for (let i = 0; i < N; i++) {
v.set(zelle[i * 2], W.haken.hoehe, zelle[i * 2 + 1]).project(kamera);
const x = (v.x + 1) / 2 * b, y = (1 - v.y) / 2 * h;
let best = 1e9, s = 0;
[[P[0], P[1], 0, l1], [P[1], P[2], l1, l2]].forEach(([A, B, off, len]) => {
const dx = B[0] - A[0], dy = B[1] - A[1], t = Math.max(0, Math.min(1, ((x - A[0]) * dx + (y - A[1]) * dy) / (len * len)));
const d = Math.hypot(x - A[0] - dx * t, y - A[1] - dy * t);
if (d < best) { best = d; s = (off + t * len) / L; }
});
daten[i * 4 + 1] = best < dicke ? s : -1;
if (best < dicke) hakenZahl++;
}
datenAttr.needsUpdate = true;
zeigeWerte(renderer.info.render.calls || 1);
}
addEventListener('resize', layout);
layout();
// Zeiger → Ebene y = 0,5 → Zellkoordinaten
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), ebene = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.5), treffer = new THREE.Vector3();
let zielAn = 0, mausAn = 0, ziel = new THREE.Vector2(0, 0), virtuell = null, losgelassen = 0;
function aufFeld(cx, cy) {
const r = canvas.getBoundingClientRect();
ndc.set((cx - r.left) / r.width * 2 - 1, -((cy - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, kamera);
if (ray.ray.intersectPlane(ebene, treffer)) { ziel.set(treffer.x, treffer.z); return true; }
return false;
}
buehne.addEventListener('pointermove', e => { if (vorfuehrend || reduziert()) return; if (aufFeld(e.clientX, e.clientY)) zielAn = 1; });
buehne.addEventListener('pointerdown', e => { if (reduziert() || e.target.closest('a, button')) return; if (aufFeld(e.clientX, e.clientY)) { zielAn = 1; if (e.pointerType !== 'mouse') u.uMaus.value.copy(ziel); } });
buehne.addEventListener('pointerleave', () => { zielAn = 0; });
addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') losgelassen = performance.now(); });
let zeit = 0, pausiert = false, letzt = performance.now(), gestartet = false, sichtbar = true;
// außerhalb des Viewports nicht rendern
new IntersectionObserver(e => { sichtbar = e[0].isIntersecting; if (sichtbar) letzt = performance.now(); }).observe(feld);
const vz = new THREE.Vector3();
function frame(jetzt) {
requestAnimationFrame(frame);
const dt = Math.min((jetzt - letzt) / 1000, 1 / 20); letzt = jetzt;
if (pausiert || !sichtbar) return;
const red = reduziert();
if (!red) zeit += dt;
// Touch: nach dem Loslassen klingt die Welle in 0,9 s aus
if (losgelassen && jetzt - losgelassen > 900) { zielAn = 0; losgelassen = 0; }
if (virtuell) {
const t = zeit - virtuell.t0, sx = SP * (istMobil ? 0.24 : 0.17), sz = ZE * (istMobil ? 0.24 : 0.2);
ziel.set(Math.sin(t * 0.62 + 0.4) * sx + (istMobil ? 0 : 2), Math.sin(t * 1.04 + 1.2) * sz - (istMobil ? 0 : 1));
zielAn = 1;
vz.set(u.uMaus.value.x, 0.5, u.uMaus.value.y).project(kamera);
const r = canvas.getBoundingClientRect(), br = buehne.getBoundingClientRect();
zeiger.style.transform = `translate(${(vz.x + 1) / 2 * r.width + r.left - br.left}px, ${(1 - vz.y) / 2 * r.height + r.top - br.top}px)`;
}
const k = 1 - Math.exp(-dt * W.lerp);
u.uMaus.value.lerp(ziel, k);
mausAn += ((red ? 0 : zielAn) - mausAn) * (1 - Math.exp(-dt * 5));
u.uMausAn.value = mausAn;
u.uZeit.value = zeit;
renderer.render(szene, kamera);
}
function start() { if (gestartet) return; gestartet = true; requestAnimationFrame(t => { letzt = t; frame(t); requestAnimationFrame(() => html.classList.add('gl-bereit')); }); }
function wechsel(nach, von) {
u.uVon.value = von ?? u.uNach.value; u.uNach.value = nach;
u.uWechsel.value = reduziert() ? zeit - 10 : zeit;
if (reduziert() && gestartet) { canvas.classList.add('blende'); setTimeout(() => canvas.classList.remove('blende'), W.blendeMs[0] + 30); }
}
return {
neustart() { u.uFertig.value.fill(-100); u.uFertigZahl.value = 0; wechsel(0, 3); start(); },
modus(i) {
// Stand nur beim Betreten des Lernwegs zurücksetzen: beim Verlassen ist er der Ausgangszustand der Überblendung
if (i === 1) { u.uFertig.value.fill(-100); u.uFertigZahl.value = 0; }
if (u.uNach.value !== i || !gestartet) wechsel(i); if (!gestartet) start();
},
fertig(i) { u.uFertig.value[i] = reduziert() ? zeit - 10 : zeit; u.uFertigZahl.value = i + 1; },
pause(an) { pausiert = an; if (!an) letzt = performance.now(); },
virtuell(an) { virtuell = an ? { t0: zeit } : null; zeiger.classList.toggle('an', !!an && !reduziert()); if (!an) zielAn = 0; },
};
}
/* ======================================================================
Ersatz ohne WebGL: isometrisches Feld auf Canvas2D, statisch
====================================================================== */
function ohneWebGL() {
html.classList.add('ohne-webgl');
const g = ersatz.getContext('2d');
let m = 0, f = 0;
const haken = new Uint8Array(N);
function hoehe(i) {
const band = daten[i * 4], z = daten[i * 4 + 2], x = zelle[i * 2], y = zelle[i * 2 + 1];
if (m === 0) return 0.36 + W.ruhe * Math.sin(x * 0.32) * Math.cos(y * 0.27) + 0.08 * z;
if (m === 1) return band < -0.5 ? 0.12 : W.treppe.basis + band * W.treppe.stufe + (band < f ? W.treppe.bonus : 0);
return haken[i] ? W.haken.hoehe : W.haken.flach;
}
function farbe(i) { const band = daten[i * 4]; if (m === 2 && haken[i]) return [16, 191, 122]; return m === 1 && band >= 0 && band < f ? [16, 191, 122] : m === 1 && band === f ? [66, 87, 245] : [251, 252, 254]; }
function male() {
const b = feld.clientWidth, h = feld.clientHeight, dpr = Math.min(devicePixelRatio, 2);
ersatz.width = b * dpr; ersatz.height = h * dpr; g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.fillStyle = FARBE.grund; g.fillRect(0, 0, b, h);
const s = schmal() ? b / (SP * 1.1) : b / (SP * 1.05), cx = schmal() ? b / 2 : b * 0.64, cy = h * 0.52;
const ca = Math.cos(0.7), sa = Math.sin(0.7), ky = 0.58;
const P = (x, y, z) => [cx + (x * ca - z * sa) * s, cy + (x * sa + z * ca) * s * ky - y * s];
const ord = [...Array(N).keys()].sort((a, c) => (zelle[a * 2] * sa + zelle[a * 2 + 1] * ca) - (zelle[c * 2] * sa + zelle[c * 2 + 1] * ca));
const r = W.wuerfel / 2;
if (m === 2) { // Haken im Bildraum wie in der WebGL-Fassung
const S = schmal() ? Math.min(b * 0.66, h * 0.62) : Math.min(b * 0.3, h * 0.48), Q = [[-0.44, -0.02], [-0.13, 0.27], [0.46, -0.34]].map(([x, y]) => [cx + x * S, cy + y * S]);
for (let i = 0; i < N; i++) {
const [x, y] = P(zelle[i * 2], W.haken.hoehe, zelle[i * 2 + 1]);
haken[i] = [[Q[0], Q[1]], [Q[1], Q[2]]].some(([A, B]) => { const dx = B[0] - A[0], dy = B[1] - A[1], t = Math.max(0, Math.min(1, ((x - A[0]) * dx + (y - A[1]) * dy) / (dx * dx + dy * dy))); return Math.hypot(x - A[0] - dx * t, y - A[1] - dy * t) < S * 0.07; }) ? 1 : 0;
}
hakenZahl = haken.reduce((a, c) => a + c, 0); zeigeWerte();
}
for (const i of ord) {
const x = zelle[i * 2], z = zelle[i * 2 + 1], hh = hoehe(i), [cr, cg, cb] = farbe(i);
const ecke = (dx, dz, y) => P(x + dx, y, z + dz);
const poly = (pts, k) => { g.fillStyle = `rgb(${cr * k | 0},${cg * k | 0},${cb * k | 0})`; g.beginPath(); pts.forEach((p, j) => j ? g.lineTo(...p) : g.moveTo(...p)); g.fill(); };
poly([ecke(r, -r, 0), ecke(r, r, 0), ecke(r, r, hh), ecke(r, -r, hh)], 0.84);
poly([ecke(-r, r, 0), ecke(r, r, 0), ecke(r, r, hh), ecke(-r, r, hh)], 0.7);
poly([ecke(-r, -r, hh), ecke(r, -r, hh), ecke(r, r, hh), ecke(-r, r, hh)], 1);
}
}
addEventListener('resize', male);
return {
neustart() { m = 0; f = 0; male(); },
modus(i) { m = i; f = 0; male(); },
fertig(i) { f = i + 1; male(); },
pause() {}, virtuell() {},
};
}
</script>
</body>
</html>