<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title><![CDATA[Cinevva Tutorials]]></title>
        <link>https://app.cinevva.com</link>
        <description><![CDATA[Praktische Tutorials für Entwickler von Browser-Games.]]></description>
        <lastBuildDate>Tue, 25 Aug 2026 20:24:23 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>scripts/build-rss.mjs</generator>
        <copyright>Copyright 2024-present Cinevva</copyright>
        <item>
            <title><![CDATA[Agentische KI-Codewerkzeuge]]></title>
            <link>https://app.cinevva.com/de/tutorials/agentic-code-tools</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/agentic-code-tools</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Claude Code, Cursor und GitHub Copilot meistern: Verstehe agentische Workflows, Best Practices und wann du welches Werkzeug für maximale Produktivität einsetzen solltest.]]></description>
            <content:encoded><![CDATA[<h1>Agentische KI-Codewerkzeuge</h1>
<p>KI-Programmierassistenten haben sich von einfacher Autovervollständigung zu autonomen Agenten entwickelt, die planen, mehrere Dateien bearbeiten, Tests ausführen und Pull Requests erstellen können. Dieses Tutorial behandelt die drei wichtigsten Werkzeuge: Claude Code, Cursor und GitHub Copilot.</p>
<h2>1) Was macht ein Werkzeug „agentisch“?</h2>
<p>Herkömmliche KI-Codevervollständigung schlägt die nächsten Zeilen vor. Agentische Werkzeuge gehen weiter:</p>
<table>
<thead>
<tr>
<th>Fähigkeit</th>
<th>Autovervollständigung</th>
<th>Agentisch</th>
</tr>
</thead>
<tbody>
<tr>
<td>Umfang</td>
<td>Aktuelle Zeile/Funktion</td>
<td>Gesamte Codebasis</td>
</tr>
<tr>
<td>Planung</td>
<td>Keine</td>
<td>Mehrstufiges Schlussfolgern</td>
</tr>
<tr>
<td>Ausführung</td>
<td>Nur Vorschläge</td>
<td>Kann Dateien bearbeiten und Befehle ausführen</td>
</tr>
<tr>
<td>Kontext</td>
<td>Aktuelle Datei</td>
<td>Verständnis des gesamten Repositorys</td>
</tr>
<tr>
<td>Workflow</td>
<td>Reaktiv</td>
<td>Autonome Aufgabenerledigung</td>
</tr>
</tbody>
</table>
<p>Agentische Werkzeuge können deine gesamte Projektstruktur verstehen, Änderungen über mehrere Dateien hinweg planen, diese Änderungen ausführen, Tests starten und iterieren, bis die Aufgabe abgeschlossen ist.</p>
<h2>2) Kurzer Vergleich: Claude Code vs. Cursor vs. Copilot</h2>
<table>
<thead>
<tr>
<th>Funktion</th>
<th>Claude Code</th>
<th>Cursor</th>
<th>GitHub Copilot</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Oberfläche</strong></td>
<td>CLI, Web, IDE-Plugins</td>
<td>Vollständige IDE (VS-Code-Fork)</td>
<td>IDE-Erweiterungen</td>
</tr>
<tr>
<td><strong>Kontextfenster</strong></td>
<td>Groß (Opus mit langem Kontext)</td>
<td>Indexierung des gesamten Repositorys</td>
<td>Kleiner, wird verbessert</td>
</tr>
<tr>
<td><strong>Bearbeitung mehrerer Dateien</strong></td>
<td>Stark</td>
<td>Hervorragend</td>
<td>Wird verbessert</td>
</tr>
<tr>
<td><strong>Planungsmodus</strong></td>
<td>Ja</td>
<td>Ja</td>
<td>Ja (neuer)</td>
</tr>
<tr>
<td><strong>Preismodell</strong></td>
<td>Pro-/Max-Tarife</td>
<td>Credits + Abonnement</td>
<td>Abonnement</td>
</tr>
<tr>
<td><strong>Am besten geeignet für</strong></td>
<td>Architektur, große Refactorings</td>
<td>Interaktive Entwicklung</td>
<td>Programmieren direkt im Editor</td>
</tr>
</tbody>
</table>
<h2>3) Grundlagen von Claude Code</h2>
<p>Claude Code läuft auf Opus 4.8 (dem derzeitigen Standardmodell) und bietet große Kontextfenster. So installierst und authentifizierst du es:</p>
<pre><code class="language-bash"># Claude Code installieren (nativer Installer, empfohlen)
curl -fsSL https://claude.ai/install.sh | bash

# Oder über npm (erfordert Node 18+)
npm install -g @anthropic-ai/claude-code

# Authentifizieren: claude ausführen und der Aufforderung im Browser folgen
claude

# In deinem Projekt starten
cd your-project
claude
</code></pre>
<h3>Grundlegende Befehle</h3>
<pre><code class="language-bash"># Fragen zu deiner Codebasis stellen
claude &quot;Wie ist die Authentifizierung hier implementiert?&quot;

# Code generieren
claude &quot;Füge eine Rate-Limiter-Middleware hinzu&quot;

# Refactoring über mehrere Dateien
claude &quot;Benenne UserService in der gesamten Codebasis in AuthService um&quot;

# Einen PR erstellen
claude &quot;Erstelle einen PR, der allen API-Endpunkten eine Eingabevalidierung hinzufügt&quot;
</code></pre>
<h3>Projektkonfiguration</h3>
<p>Lege den Projektkontext in einer <code>CLAUDE.md</code>-Datei im Stammverzeichnis deines Repositorys ab. Claude Code liest sie automatisch zu Beginn jeder Sitzung:</p>
<pre><code class="language-markdown"># Projekthinweise

- Sprache: TypeScript, mit Prettier formatiert
- Tests: Vitest, in tests/
- node_modules/ und dist/ nicht verändern
</code></pre>
<p>Maschinenlesbare Einstellungen (Werkzeugberechtigungen, Umgebungsvariablen, Hooks) gehören in <code>.claude/settings.json</code>. Um beispielsweise vor der Ausführung destruktiver Werkzeuge eine Genehmigung zu verlangen, konfigurierst du dort die Berechtigungen.</p>
<h3>Gedächtnis und Kontext</h3>
<p>Dauerhafter Projektkontext befindet sich in CLAUDE.md-Dateien, die Claude Code zu Beginn jeder Sitzung lädt, sowie in einem automatischen Gedächtnisspeicher, den Claude anhand deiner Korrekturen und Präferenzen selbst aktualisiert. Um dauerhaften Kontext bereitzustellen, bearbeitest du <a href="http://CLAUDE.md">CLAUDE.md</a>, statt eine einmalige „Merke dir:“-Anweisung zu übergeben:</p>
<pre><code class="language-markdown"># CLAUDE.md
Dies ist ein Monorepo. Gemeinsam genutzter Code befindet sich in packages/.
</code></pre>
<p>Verwende innerhalb einer Sitzung den Befehl <code>/clear</code>, um den Arbeitskontext zurückzusetzen.</p>
<h2>4) Agentische Funktionen der Cursor-IDE</h2>
<p>Cursor wurde rund um agentische Workflows entwickelt. Zu den wichtigsten Funktionen gehören:</p>
<h3>Agentenmodus</h3>
<p>Drücke <code>Cmd+Shift+I</code> (Mac) oder <code>Ctrl+Shift+I</code> (Windows/Linux), um Composer im Agentenmodus zu öffnen:</p>
<pre><code>Workflow im Agentenmodus:
1. Beschreibe deine Aufgabe
2. Der Agent erstellt einen Plan
3. Du genehmigst oder änderst ihn
4. Der Agent führt die Änderungen aus
5. Überprüfen und iterieren
</code></pre>
<h3>Planungsmodus</h3>
<p>Verwende bei komplexen Aufgaben ausdrücklich den Planungsmodus:</p>
<pre><code>@plan Füge eine Benutzerauthentifizierung mit folgenden Funktionen hinzu:
- JWT-Tokens
- Rotation von Refresh-Tokens
- Passwort-Hashing mit bcrypt
- Ratenbegrenzung für Anmeldeversuche
</code></pre>
<p>Der Agent wird:</p>
<ol>
<li>Deinen bestehenden Code untersuchen</li>
<li>Einen detaillierten Implementierungsplan erstellen</li>
<li>Auf deine Genehmigung warten</li>
<li>Den Plan Schritt für Schritt ausführen</li>
</ol>
<h3>Kontext einfügen</h3>
<p>Verwende <code>@</code>, um gezielten Kontext hinzuzufügen:</p>
<pre><code>@file src/api/auth.ts
@folder src/middleware
@docs https://jwt.io/introduction

Implementiere eine Middleware zur JWT-Verifizierung nach diesen Mustern
</code></pre>
<h3>Parallele Agenten</h3>
<p>Führe mehrere Agenten gleichzeitig in verschiedenen Branches aus:</p>
<pre><code># In Cursor:
1. Befehlspalette öffnen
2. &quot;Cursor: New Agent in Worktree&quot;
3. Jeder Agent arbeitet isoliert
4. Ergebnisse nebeneinander vergleichen
</code></pre>
<p>Cursor 3 hat ein Agents Window eingeführt (öffne es mit Cmd+Shift+P und anschließend „Agents Window“), das mehrere Agenten parallel über lokale Checkouts, Git-Worktrees und Cloud-Umgebungen hinweg ausführt. Der Befehl /worktree startet jeden Agenten in einem eigenen isolierten Git-Worktree, während /best-of-n dieselbe Aufgabe mit mehreren Modellen ausführt, sodass du die Ergebnisse nebeneinander vergleichen kannst.</p>
<h3>Regeln für Konsistenz</h3>
<p>Erstelle <code>.cursor/rules/project.mdc</code>:</p>
<pre><code class="language-markdown"># Projektregeln

## Codestil
- TypeScript im Strict Mode verwenden
- Funktionale Komponenten bevorzugen
- Benannte Exporte statt Standardexporten verwenden

## Tests
- Jede Funktion benötigt Tests
- describe/it-Blöcke verwenden
- Externe Dienste mocken

## Sicherheit
- Niemals Geheimnisse committen
- Alle Benutzereingaben validieren
- Parametrisierte Abfragen verwenden
</code></pre>
<h2>5) Agentische Funktionen von GitHub Copilot</h2>
<p>Copilot hat sich über die Autovervollständigung hinaus weiterentwickelt:</p>
<h3>Copilot Chat</h3>
<p>Öffne den Chatbereich und verwende Slash-Befehle:</p>
<pre><code>/explain - Ausgewählten Code erklären
/fix - Probleme in der Auswahl beheben
/tests - Tests generieren
/doc - Dokumentation hinzufügen
</code></pre>
<h3>Coding-Agent (vom Issue zum Pull Request)</h3>
<pre><code># Auf GitHub:
1. Ein GitHub-Issue öffnen
2. &quot;Copilot&quot; als Bearbeiter zuweisen
3. Der Coding-Agent arbeitet im Hintergrund:
   Er analysiert das Issue, bearbeitet Dateien und führt Tests aus
4. Er öffnet einen Pull Request zur Überprüfung
</code></pre>
<p>Dies unterscheidet sich vom Agentenmodus von Copilot innerhalb der IDE, der in VS Code oder JetBrains synchron an deiner Seite arbeitet.</p>
<p>Copilot teilt seine autonome Arbeit inzwischen in zwei Bereiche auf: Der Agentenmodus läuft synchron in deiner IDE (VS Code und JetBrains), entscheidet, welche Dateien bearbeitet werden müssen, führt Terminalbefehle aus und iteriert bei Fehlern. Der Coding-Agent läuft dagegen asynchron in der Cloud, übernimmt ein zugewiesenes Issue und liefert einen Pull Request, während du an etwas anderem arbeitest.</p>
<h3>Inline-Vorschläge mit Kontext</h3>
<p>Copilot liest deine Codebasis. Unterstütze es mit Kommentaren:</p>
<pre><code class="language-typescript">// Authentifizierungs-Middleware, die:
// - das JWT aus dem Authorization-Header extrahiert
// - die Token-Signatur validiert
// - den Benutzer an die Anfrage anhängt
// - bei einem ungültigen Token 401 zurückgibt
export function authMiddleware(req, res, next) {
  // Copilot generiert die Implementierung
}
</code></pre>
<h2>6) Best Practices für alle Werkzeuge</h2>
<h3>Klare Prompts schreiben</h3>
<pre><code>❌ Schlecht: &quot;Behebe den Fehler&quot;

✅ Gut: &quot;Behebe die Nullzeiger-Ausnahme in UserService.getById,
wenn der Benutzer nicht existiert. Gib null zurück, statt eine Ausnahme auszulösen.&quot;

❌ Schlecht: &quot;Füge Tests hinzu&quot;

✅ Gut: &quot;Füge Unit-Tests für CartService hinzu, die Folgendes abdecken:
- Artikel zu einem leeren Warenkorb hinzufügen
- Artikel entfernen
- Summen mit Rabatten berechnen
- Grenzfall: negative Mengen&quot;
</code></pre>
<h3>Bei komplexen Aufgaben den Planungsmodus verwenden</h3>
<pre><code>Bei jeder Aufgabe, die mindestens 3 Dateien betrifft:

1. Zuerst nach einem Plan fragen
2. Den Plan überprüfen
3. Genehmigen oder ändern
4. Den Agenten die Aufgabe ausführen lassen
5. Änderungen vor dem Committen überprüfen
</code></pre>
<h3>Architekturkontext bereitstellen</h3>
<pre><code>Informiere den Agenten vor größeren Arbeiten:

&quot;Dies ist eine Microservices-Architektur:
- api-gateway: Leitet Anfragen weiter
- auth-service: JWT, Sitzungen
- user-service: Benutzer-CRUD
- notification-service: E-Mail, Push-Benachrichtigungen

Die Dienste kommunizieren über Redis Pub/Sub.
Die Datenbank ist PostgreSQL mit Prisma ORM.&quot;
</code></pre>
<h3>Testgetriebene Entwicklung mit Agenten</h3>
<pre><code>1. Zuerst einen fehlschlagenden Test schreiben:
   &quot;Schreibe einen Test für die Benutzerregistrierung, der eine E-Mail-Validierung erwartet&quot;

2. Dem Agenten den fehlschlagenden Test zeigen:
   &quot;Implementiere die Registrierung so, dass dieser Test erfolgreich ist&quot;

3. Iterieren, bis alle Tests erfolgreich sind:
   &quot;Behebe die verbleibenden Testfehler&quot;
</code></pre>
<h2>7) Sicherheitsaspekte</h2>
<p>Agentische Werkzeuge haben echte Befugnisse. Setze Grenzen:</p>
<h3>Genehmigungsschranken</h3>
<pre><code class="language-json">// .claude/config.json
{
  &quot;safety&quot;: {
    &quot;requireApproval&quot;: [
      &quot;rm&quot;,
      &quot;git push&quot;,
      &quot;npm publish&quot;,
      &quot;database migrations&quot;,
      &quot;env file changes&quot;
    ]
  }
}
</code></pre>
<h3>Sandboxing</h3>
<pre><code class="language-bash"># Agenten in isolierten Umgebungen ausführen
docker run -v $(pwd):/workspace -it claude-sandbox

# Oder Git-Worktrees zur Isolation verwenden
git worktree add ../feature-branch feature-branch
</code></pre>
<h3>Vertrauliche Dateien</h3>
<pre><code># .cursorignore / .claudeignore
.env
.env.*
secrets/
credentials/
*.pem
*.key
</code></pre>
<h3>Alle Änderungen überprüfen</h3>
<pre><code class="language-bash"># Vor dem Akzeptieren von Änderungen des Agenten:
git diff --stat
git diff

# Interaktives Staging verwenden
git add -p

# Niemals ohne Überprüfung automatisch mergen
</code></pre>
<h2>8) Bewährte Workflow-Muster</h2>
<h3>Muster 1: Erkunden, planen, ausführen</h3>
<pre><code>Sitzung 1: &quot;Wie funktioniert das Zahlungssystem?&quot;
           → Der Agent erkundet es, du lernst es kennen

Sitzung 2: &quot;Was wäre erforderlich, um Stripe hinzuzufügen?&quot;
           → Der Agent erstellt einen Plan

Sitzung 3: &quot;Implementiere die Stripe-Integration&quot;
           → Der Agent führt den Plan aus
</code></pre>
<h3>Muster 2: Parallele Erkundung</h3>
<pre><code># In Cursor mit parallelen Agenten:

Agent 1: &quot;Implementiere Caching mit Redis&quot;
Agent 2: &quot;Implementiere Caching mit Memcached&quot;
Agent 3: &quot;Implementiere Caching mit einem In-Memory-LRU-Cache&quot;

→ Implementierungen vergleichen und den besten Ansatz auswählen
</code></pre>
<h3>Muster 3: Schrittweise Migration</h3>
<pre><code>Schritt 1: &quot;Ermittle alle Verwendungen der veralteten API&quot;
Schritt 2: &quot;Erstelle einen Adapter für die neue API&quot;
Schritt 3: &quot;Migriere Modul A zur Verwendung des Adapters&quot;
Schritt 4: &quot;Migriere Modul B ...&quot;
...
Schritt N: &quot;Entferne den Adapter und verwende die neue API direkt&quot;
</code></pre>
<h3>Muster 4: Testorientierte Funktionsentwicklung</h3>
<pre><code>1. &quot;Schreibe Akzeptanztests für die Bearbeitung von Benutzerprofilen&quot;
2. &quot;Schreibe Unit-Tests für die Profilvalidierung&quot;
3. &quot;Implementiere die Funktion so, dass die Tests erfolgreich sind&quot;
4. &quot;Füge Integrationstests hinzu&quot;
5. &quot;Führe ein Refactoring für bessere Verständlichkeit durch&quot;
</code></pre>
<h2>9) Wann welches Werkzeug verwenden?</h2>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Empfohlenes Werkzeug</th>
</tr>
</thead>
<tbody>
<tr>
<td>Programmieren in Echtzeit, Inline-Unterstützung</td>
<td>GitHub Copilot</td>
</tr>
<tr>
<td>Große Refactorings, Architektur</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Vollständige Entwicklungssitzung</td>
<td>Cursor</td>
</tr>
<tr>
<td>Kurze Fragen zum Code</td>
<td>Beliebige Chatoberfläche</td>
</tr>
<tr>
<td>PRs aus Issues erstellen</td>
<td>Copilot Workspace</td>
</tr>
<tr>
<td>Komplexe Änderungen über mehrere Dateien</td>
<td>Cursor Agent</td>
</tr>
<tr>
<td>API-Design, Dokumentation</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Eine neue Codebasis kennenlernen</td>
<td>Cursor oder Claude Code</td>
</tr>
</tbody>
</table>
<h2>10) Effektivität messen</h2>
<p>Verfolge diese Kennzahlen, um agentische Werkzeuge zu bewerten:</p>
<pre><code class="language-javascript">const metrics = {
  // Produktivität
  suggestionsAccepted: 'Prozentsatz der Vorschläge, die du übernommen hast',
  timeToFeature: 'Stunden vom Beginn bis zum gemergten PR',
  linesPerHour: 'Codegeschwindigkeit (mit Bedacht verwenden)',
  
  // Qualität
  bugsIntroduced: 'Fehler, die auf KI-generierten Code zurückzuführen sind',
  testCoverage: 'Testabdeckung des KI-generierten Codes',
  reviewChanges: 'Umfang deiner Änderungen an KI-Vorschlägen',
  
  // Kosten
  apiCosts: 'monatliche Ausgaben für KI-Werkzeuge',
  creditsUsed: 'verbrauchte Cursor-Credits',
  
  // Lernen
  newPatternsLearned: 'Techniken, die du von der KI gelernt hast',
  codebaseUnderstanding: 'wie gut dir die KI beim Verständnis der Codebasis geholfen hat',
}
</code></pre>
<h2>11) Häufige Fallstricke</h2>
<h3>Dem Agenten zu sehr vertrauen</h3>
<pre><code>❌ Große Änderungen ohne Überprüfung akzeptieren
❌ Agenten destruktive Befehle ausführen lassen
❌ Code veröffentlichen, ohne ihn zu verstehen

✅ Jede Änderung überprüfen
✅ Verstehen, was du veröffentlichst
✅ Agenten als Mitwirkende und nicht als Ersatz verwenden
</code></pre>
<h3>Kontextverschmutzung</h3>
<pre><code>❌ Riesige Prompts mit irrelevantem Kontext
❌ Kontext zwischen unabhängigen Aufgaben nicht löschen
❌ Generierte Dateien in den Kontext aufnehmen

✅ Prompts fokussiert halten
✅ Gezielte @file-Verweise verwenden
✅ Build-Artefakte ausschließen
</code></pre>
<h3>Mehrdeutige Prompts</h3>
<pre><code>❌ &quot;Mach es besser&quot;
❌ &quot;Behebe die Probleme&quot;
❌ &quot;Füge einige Tests hinzu&quot;

✅ &quot;Reduziere die Zeitkomplexität von O(n²) auf O(n log n)&quot;
✅ &quot;Behandle den null-Fall in Zeile 45 durch eine vorzeitige Rückgabe&quot;
✅ &quot;Füge Tests für Grenzfälle hinzu: leere Eingabe, maximale Länge, Unicode&quot;
</code></pre>
<h2>12) Checkliste für den Einstieg</h2>
<pre><code class="language-markdown">## Einrichtungscheckliste

### Claude Code
- [ ] CLI installieren: `npm install -g @anthropic/claude-code`
- [ ] Authentifizieren: `claude login`
- [ ] `.claude/config.json` erstellen
- [ ] `.claudeignore` für vertrauliche Dateien hinzufügen

### Cursor
- [ ] Von cursor.com herunterladen
- [ ] VS-Code-Einstellungen importieren
- [ ] `.cursor/rules/` konfigurieren
- [ ] `Cmd+Shift+I` für den Agentenmodus lernen

### GitHub Copilot
- [ ] Erweiterung in deiner IDE installieren
- [ ] Copilot Chat aktivieren
- [ ] Copilot Workspace für Issues ausprobieren
- [ ] `.github/copilot-instructions.md` konfigurieren

### Alle Werkzeuge
- [ ] Genehmigungsschranken für destruktive Aktionen einrichten
- [ ] Ignore-Dateien für Geheimnisse erstellen
- [ ] Überprüfungsworkflow festlegen
- [ ] Kennzahlen vom ersten Tag an erfassen
</code></pre>
<h2>Verwandte Themen</h2>
<ul>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Webspiel veröffentlichen, das schnell lädt</a></li>
<li><a href="/de/tutorials/web-workers-game-logic">Web Workers für Spiellogik</a></li>
<li><a href="/de/guides/game-jams-hackathons">Game Jams &amp; Hackathons</a> — KI-Codewerkzeuge verändern, was in einem 48-stündigen Jam möglich ist</li>
<li><a href="/de/guides/web-game-engines-comparison">Webspiel-Engines im Vergleich</a> — eine Engine auswählen, die gut zu KI-gestützten Workflows passt</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Analytik und Telemetrie für Web-Spiele]]></title>
            <link>https://app.cinevva.com/de/tutorials/analytics-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/analytics-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Verfolgen, worauf es in deinem Spiel ankommt: Spielerverhalten, Performance, Fehler und Conversions. Entscheide datenbasiert.]]></description>
            <content:encoded><![CDATA[<h1>Analytik und Telemetrie für Web-Spiele</h1>
<p>Analytik hilft dir zu verstehen, was Spieler tun, wo sie hängen bleiben und was sie bei der Stange hält. Dieses Tutorial zeigt, was du verfolgen solltest und wie.</p>
<h2>1) Was du verfolgen solltest</h2>
<p><strong>Engagement-Kennzahlen:</strong></p>
<ul>
<li>Sitzungsstarts und Dauer</li>
<li>Begonnene und abgeschlossene Level</li>
<li>Genutzte Funktionen</li>
<li>Bindung (Wiederbesuche)</li>
</ul>
<p><strong>Performance-Kennzahlen:</strong></p>
<ul>
<li>Ladezeit</li>
<li>Bildrate</li>
<li>Speicherverbrauch</li>
<li>Fehler und Abstürze</li>
</ul>
<p><strong>Conversion-Kennzahlen:</strong></p>
<ul>
<li>Abschluss des Tutorials</li>
<li>Erster Kauf</li>
<li>Teilen in sozialen Netzwerken</li>
</ul>
<h2>2) Einfaches Event-Tracking</h2>
<pre><code class="language-js">class Analytics {
  constructor(endpoint) {
    this.endpoint = endpoint
    this.sessionId = crypto.randomUUID()
    this.queue = []
    this.flushInterval = 30000 // 30 seconds
    
    setInterval(() =&gt; this.flush(), this.flushInterval)
    // Flush when the page is being hidden, not on beforeunload.
    // beforeunload/unload are unreliable (often don't fire on mobile,
    // and block the back/forward cache). visibilitychange + pagehide
    // are the recommended events for end-of-session sends.
    document.addEventListener('visibilitychange', () =&gt; {
      if (document.visibilityState === 'hidden') this.flush()
    })
    window.addEventListener('pagehide', () =&gt; this.flush())
  }
  
  track(event, data = {}) {
    this.queue.push({
      event,
      data,
      sessionId: this.sessionId,
      timestamp: Date.now(),
      url: location.href,
    })
    
    // Flush immediately for important events
    if (event === 'error' || event === 'purchase') {
      this.flush()
    }
  }
  
  async flush() {
    if (this.queue.length === 0) return
    
    const events = [...this.queue]
    this.queue = []
    
    try {
      await fetch(this.endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ events }),
        keepalive: true, // Important for beforeunload
      })
    } catch {
      // Put events back in queue
      this.queue.unshift(...events)
    }
  }
}

const analytics = new Analytics('/api/analytics')
</code></pre>
<h2>3) Sitzungen verfolgen</h2>
<pre><code class="language-js">// Track session start
analytics.track('session_start', {
  referrer: document.referrer,
  screen: `${screen.width}x${screen.height}`,
  devicePixelRatio: window.devicePixelRatio,
  userAgent: navigator.userAgent,
})

// Track session end
let sessionStart = Date.now()

// Send session_end when the page is hidden, not on beforeunload.
// beforeunload is unreliable on mobile and breaks the bfcache.
function recordSessionEnd() {
  analytics.track('session_end', {
    duration: Date.now() - sessionStart,
  })
}
document.addEventListener('visibilitychange', () =&gt; {
  if (document.visibilityState === 'hidden') recordSessionEnd()
})
window.addEventListener('pagehide', recordSessionEnd)

// Track visibility changes
document.addEventListener('visibilitychange', () =&gt; {
  if (document.hidden) {
    analytics.track('tab_hidden')
  } else {
    analytics.track('tab_visible')
  }
})
</code></pre>
<h2>4) Spielspezifische Events</h2>
<pre><code class="language-js">// Level tracking
function onLevelStart(levelId) {
  analytics.track('level_start', { levelId })
}

function onLevelComplete(levelId, score, time) {
  analytics.track('level_complete', {
    levelId,
    score,
    timeSeconds: time,
  })
}

function onLevelFail(levelId, reason) {
  analytics.track('level_fail', {
    levelId,
    reason, // 'death', 'timeout', 'quit'
  })
}

// Achievement tracking
function onAchievementUnlocked(achievementId) {
  analytics.track('achievement', { achievementId })
}

// Tutorial tracking
function onTutorialStep(step, skipped = false) {
  analytics.track('tutorial', { step, skipped })
}
</code></pre>
<h2>5) Performance überwachen</h2>
<pre><code class="language-js">class PerformanceMonitor {
  constructor(analytics) {
    this.analytics = analytics
    this.frameTimes = []
    this.lastFrame = performance.now()
  }
  
  recordFrame() {
    const now = performance.now()
    this.frameTimes.push(now - this.lastFrame)
    this.lastFrame = now
    
    // Keep last 60 frames
    if (this.frameTimes.length &gt; 60) {
      this.frameTimes.shift()
    }
  }
  
  getAverageFPS() {
    if (this.frameTimes.length === 0) return 0
    const avgFrameTime = this.frameTimes.reduce((a, b) =&gt; a + b) / this.frameTimes.length
    return 1000 / avgFrameTime
  }
  
  reportPerformance() {
    const fps = this.getAverageFPS()
    const memory = performance.memory?.usedJSHeapSize
    
    this.analytics.track('performance', {
      avgFPS: Math.round(fps),
      memoryMB: memory ? Math.round(memory / 1024 / 1024) : null,
    })
  }
}

// Report every minute
const perfMonitor = new PerformanceMonitor(analytics)
setInterval(() =&gt; perfMonitor.reportPerformance(), 60000)
</code></pre>
<h3>Ruckler mit Long Animation Frames zuordnen</h3>
<p>Der FPS-Zähler oben sagt dir, <em>wann</em> Frames wegbrechen, aber nicht <em>warum</em>. Die Long-Animation-Frames-API (LoAF), ausgeliefert in Chrome und Edge 123, schließt diese Lücke. Sie markiert jeden Frame, der länger als 50 ms dauert, und schlüsselt auf, welche Skripte die Verzögerung verursacht haben, sodass du Ruckler konkretem Code zuordnen kannst, statt zu raten.</p>
<pre><code class="language-js">if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
  const observer = new PerformanceObserver((list) =&gt; {
    for (const entry of list.getEntries()) {
      analytics.track('long_frame', {
        durationMs: Math.round(entry.duration),
        blockingMs: Math.round(entry.blockingDuration),
        // scripts[] names the source URLs that ate the frame
        scripts: entry.scripts?.map((s) =&gt; s.sourceURL),
      })
    }
  })
  observer.observe({ type: 'long-animation-frame', buffered: true })
}
</code></pre>
<p>LoAF gibt es bislang nur in Chromium (kein Firefox, kein Safari), prüfe also vor der Nutzung auf Verfügbarkeit und behalte den FPS-Zähler als browserübergreifende Basis.</p>
<h2>6) Fehler verfolgen</h2>
<pre><code class="language-js">window.addEventListener('error', (event) =&gt; {
  analytics.track('error', {
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    stack: event.error?.stack,
  })
})

window.addEventListener('unhandledrejection', (event) =&gt; {
  analytics.track('error', {
    message: event.reason?.message || String(event.reason),
    type: 'unhandledrejection',
    stack: event.reason?.stack,
  })
})

// Custom error tracking
function trackGameError(context, error) {
  analytics.track('game_error', {
    context,
    message: error.message,
    stack: error.stack,
  })
}
</code></pre>
<h2>7) Ladezeiten verfolgen</h2>
<pre><code class="language-js">// Track initial load
window.addEventListener('load', () =&gt; {
  const timing = performance.timing
  const loadTime = timing.loadEventEnd - timing.navigationStart
  const domReady = timing.domContentLoadedEventEnd - timing.navigationStart
  
  analytics.track('page_load', {
    totalMs: loadTime,
    domReadyMs: domReady,
  })
})

// Track game-specific load phases
async function loadGame() {
  const start = performance.now()
  
  await loadCriticalAssets()
  const criticalTime = performance.now() - start
  
  analytics.track('load_critical', { ms: Math.round(criticalTime) })
  
  await loadGameAssets()
  const totalTime = performance.now() - start
  
  analytics.track('load_complete', { ms: Math.round(totalTime) })
}
</code></pre>
<h2>8) Funnel verfolgen</h2>
<p>Verfolge, wie Spieler durch zentrale Abläufe kommen:</p>
<pre><code class="language-js">class FunnelTracker {
  constructor(analytics, funnelName) {
    this.analytics = analytics
    this.funnelName = funnelName
    this.startTime = Date.now()
  }
  
  step(stepName) {
    this.analytics.track('funnel_step', {
      funnel: this.funnelName,
      step: stepName,
      elapsedMs: Date.now() - this.startTime,
    })
  }
  
  complete() {
    this.analytics.track('funnel_complete', {
      funnel: this.funnelName,
      totalMs: Date.now() - this.startTime,
    })
  }
  
  abandon(reason) {
    this.analytics.track('funnel_abandon', {
      funnel: this.funnelName,
      reason,
      elapsedMs: Date.now() - this.startTime,
    })
  }
}

// Usage
const onboarding = new FunnelTracker(analytics, 'onboarding')
onboarding.step('welcome_shown')
// ... player clicks continue
onboarding.step('name_entered')
// ... player completes tutorial
onboarding.complete()
</code></pre>
<h2>9) Unterstützung für A/B-Tests</h2>
<pre><code class="language-js">class ABTest {
  constructor(testName, variants) {
    this.testName = testName
    this.variants = variants
    
    // Get or assign variant
    const stored = localStorage.getItem(`ab_${testName}`)
    if (stored &amp;&amp; variants.includes(stored)) {
      this.variant = stored
    } else {
      this.variant = variants[Math.floor(Math.random() * variants.length)]
      localStorage.setItem(`ab_${testName}`, this.variant)
    }
    
    // Track assignment
    analytics.track('ab_assignment', {
      test: testName,
      variant: this.variant,
    })
  }
  
  getVariant() {
    return this.variant
  }
  
  trackConversion(metric) {
    analytics.track('ab_conversion', {
      test: this.testName,
      variant: this.variant,
      metric,
    })
  }
}

// Usage
const difficultyTest = new ABTest('difficulty', ['easy', 'normal', 'hard'])
game.difficulty = difficultyTest.getVariant()

// When player completes level
difficultyTest.trackConversion('level_complete')
</code></pre>
<h2>10) Datenschutz</h2>
<pre><code class="language-js">class PrivacyAwareAnalytics extends Analytics {
  constructor(endpoint) {
    super(endpoint)
    this.enabled = this.checkConsent()
  }
  
  checkConsent() {
    return localStorage.getItem('analytics_consent') === 'true'
  }
  
  setConsent(enabled) {
    localStorage.setItem('analytics_consent', enabled ? 'true' : 'false')
    this.enabled = enabled
    
    if (enabled) {
      this.track('consent_granted')
    }
  }
  
  track(event, data = {}) {
    if (!this.enabled) return
    
    // Strip PII
    const sanitized = { ...data }
    delete sanitized.email
    delete sanitized.name
    delete sanitized.ip
    
    super.track(event, sanitized)
  }
}

// Show consent dialog
function showConsentDialog() {
  const dialog = document.createElement('div')
  dialog.innerHTML = `
    &lt;p&gt;We use analytics to improve the game. Is that okay?&lt;/p&gt;
    &lt;button id=&quot;accept&quot;&gt;Accept&lt;/button&gt;
    &lt;button id=&quot;decline&quot;&gt;Decline&lt;/button&gt;
  `
  document.body.appendChild(dialog)
  
  dialog.querySelector('#accept').onclick = () =&gt; {
    analytics.setConsent(true)
    dialog.remove()
  }
  
  dialog.querySelector('#decline').onclick = () =&gt; {
    analytics.setConsent(false)
    dialog.remove()
  }
}
</code></pre>
<h2>Alternativen von Drittanbietern</h2>
<p>Wenn du nichts Eigenes bauen willst:</p>
<ul>
<li><strong>Plausible</strong> — datenschutzorientiert, einfach</li>
<li><strong>Amplitude</strong> — Produktanalytik, Funnel</li>
<li><strong>Mixpanel</strong> — Event-Tracking, Nutzerreisen</li>
<li><strong>Sentry</strong> — speziell für Fehler-Tracking</li>
</ul>
<pre><code class="language-js">// Example: Plausible
const script = document.createElement('script')
script.defer = true
script.dataset.domain = 'yourgame.com'
script.src = 'https://plausible.io/js/plausible.js'
document.head.appendChild(script)

// Track custom events
window.plausible('level_complete', { props: { level: '1' } })
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/indexeddb-game-saves">Spielstände in IndexedDB</a></li>
<li><a href="/de/creators">Für Creator</a></li>
<li><a href="/de/guides/itch-io-launch-guide">Wie du dein Spiel auf itch.io veröffentlichst</a> — Analytik auf <a href="http://itch.io">itch.io</a> und Downloads verfolgen</li>
<li><a href="/de/guides/steam-next-fest-strategy">Strategieleitfaden zum Steam Next Fest</a> — Wunschlisten und Demo-Conversion messen</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://plausible.io/">Plausible Analytics</a> — datenschutzfreundliche, schlanke Analytik</li>
<li><a href="https://posthog.com/">PostHog</a> — quelloffene Produktanalytik mit Event-Tracking</li>
<li><a href="https://sentry.io/">Sentry</a> — Fehlerüberwachung und Absturzberichte für Web-Apps</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Grundlagen der Canvas-2D-Spielschleife]]></title>
            <link>https://app.cinevva.com/de/tutorials/canvas-2d-game-loop</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/canvas-2d-game-loop</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Bau eine solide Spielschleife mit Canvas 2D. Behandelt Timing, festen Zeitschritt, Eingaben, Rendering und die typischen Fehler, über die Leute stolpern.]]></description>
            <content:encoded><![CDATA[<h1>Grundlagen der Canvas-2D-Spielschleife</h1>
<p>Die Spielschleife ist der Herzschlag deines Spiels. Sie läuft ununterbrochen, aktualisiert den Spielzustand und zeichnet Frames, so schnell der Browser es erlaubt. Machst du das richtig, wird alles andere leichter. Machst du es falsch, jagst du Fehlern hinterher, die nur auf bestimmten Rechnern auftauchen.</p>
<p><strong>Probier es aus (Pfeiltasten oder WASD):</strong></p>
<GameLoopDemo />
<h2>Die einfachste Fassung</h2>
<p>Eine auf das Wesentliche reduzierte Spielschleife:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

function gameLoop() {
  update()
  render()
  requestAnimationFrame(gameLoop)
}

function update() {
  // Game logic here
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Drawing here
}

gameLoop()
</code></pre>
<p>Das funktioniert, hat aber ein Problem. Die Funktion <code>update</code> weiß nicht, wie viel Zeit seit dem letzten Frame vergangen ist. Auf einem 144-Hz-Monitor läuft sie doppelt so schnell wie auf einem 60-Hz-Monitor. Deine Spielphysik verhält sich auf verschiedenen Rechnern unterschiedlich.</p>
<h2>Delta-Zeit behebt Bildratenprobleme</h2>
<p>Damit dein Spiel überall gleich schnell läuft, musst du verfolgen, wie viel Zeit zwischen Frames vergeht. Das nennt man Delta-Zeit, üblicherweise mit <code>dt</code> abgekürzt.</p>
<pre><code class="language-js">let lastTime = 0

function gameLoop(currentTime) {
  const dt = (currentTime - lastTime) / 1000 // convert to seconds
  lastTime = currentTime
  
  update(dt)
  render()
  
  requestAnimationFrame(gameLoop)
}

function update(dt) {
  player.x += player.speed * dt // now works at any frame rate
}

requestAnimationFrame(gameLoop)
</code></pre>
<p>Der Callback von <code>requestAnimationFrame</code> bekommt den aktuellen Zeitstempel in Millisekunden. Ich teile durch 1000, um Sekunden zu erhalten, das macht die Rechnung einfacher. Eine Geschwindigkeit von 200 heißt dann 200 Pixel pro Sekunde, und das ist intuitiv.</p>
<p>Eine Sache zum Aufpassen: Im ersten Frame ist <code>lastTime</code> gleich 0, <code>dt</code> wird also riesig. Manche initialisieren <code>lastTime</code> vor dem Start der Schleife mit <code>performance.now()</code>, andere überspringen das Update des ersten Frames.</p>
<h2>Warum requestAnimationFrame statt setInterval</h2>
<p>Du fragst dich vielleicht, warum wir <code>requestAnimationFrame</code> statt <code>setInterval</code> nutzen. Dafür gibt es mehrere Gründe.</p>
<p>Erstens synchronisiert sich <code>requestAnimationFrame</code> mit der Bildwiederholrate des Displays, du bekommst also flüssige Animation ohne Tearing. Zweitens pausiert es automatisch, wenn der Tab im Hintergrund ist, was Akku und CPU schont. Drittens kann der Browser es besser optimieren.</p>
<p>Dieses Pausieren ist meist erwünscht, heißt aber, dass dein Spiel einfriert, wenn der Nutzer den Tab wechselt. Baust du ein Multiplayer-Spiel oder etwas, das weiterlaufen muss, brauchst du einen anderen Ansatz.</p>
<h2>Fester Zeitschritt für Physik</h2>
<p>Variable Delta-Zeit funktioniert für einfache Bewegung, macht bei Physiksimulationen aber Ärger. Dauert ein Frame zu lange, tunnelt deine Figur womöglich durch eine Wand, weil die Kollisionsprüfung den Moment des Aufpralls verpasst hat.</p>
<p>Die Lösung ist ein fester Zeitschritt. Du sammelst Zeit an und führst Physik-Updates mit fester Rate aus, unabhängig davon, wie schnell Frames gerendert werden.</p>
<pre><code class="language-js">const FIXED_DT = 1 / 60 // 60 physics updates per second
let accumulator = 0
let lastTime = 0

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator &gt;= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}
</code></pre>
<p>Das <code>Math.min(..., 0.25)</code> ist wichtig. War der Tab im Hintergrund und kommt zurück, kann <code>frameTime</code> riesig sein. Ohne die Deckelung würde die while-Schleife Hunderte Updates zum Aufholen fahren und das Spiel einfrieren. Bei 0,25 Sekunden zu deckeln heißt, dass nie mehr als 15 Physik-Updates pro Frame laufen.</p>
<h2>Spielzustand organisieren</h2>
<p>Wächst dein Spiel, brauchst du einen Ort für all die Daten. Ich nehme gern ein einzelnes Objekt, das alles hält:</p>
<pre><code class="language-js">const game = {
  state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
  score: 0,
  player: {
    x: 100,
    y: 100,
    vx: 0,
    vy: 0,
    speed: 200,
    width: 32,
    height: 32,
  },
  entities: [],
}
</code></pre>
<p>So lässt sich der Spielzustand leicht speichern und laden, beim Tod des Spielers zurücksetzen und debuggen, indem du <code>game</code> in die Konsole loggst. Manche bevorzugen Klassen, aber für kleine bis mittlere Spiele reicht ein einfaches Objekt.</p>
<h2>Alles zusammen</h2>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0

const input = { left: false, right: false, up: false, down: false }

const game = {
  player: { x: 100, y: 100, speed: 200 },
}

function update(dt) {
  const p = game.player
  
  if (input.left) p.x -= p.speed * dt
  if (input.right) p.x += p.speed * dt
  if (input.up) p.y -= p.speed * dt
  if (input.down) p.y += p.speed * dt
  
  // Keep in bounds
  p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
  p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}

function render() {
  ctx.fillStyle = '#1a1a2e'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#4ade80'
  ctx.fillRect(game.player.x, game.player.y, 32, 32)
}

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator &gt;= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

// Input handling
window.addEventListener('keydown', (e) =&gt; {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})

window.addEventListener('keyup', (e) =&gt; {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})

requestAnimationFrame(gameLoop)
</code></pre>
<p>Das ist eine vollständige, lauffähige Spielschleife. Der Spieler bewegt sich mit Pfeiltasten oder WASD, bleibt im Bild und alles läuft unabhängig von der Bildrate gleich schnell. Du kannst das als Ausgangspunkt für jedes Canvas-2D-Spiel kopieren.</p>
<p>Beachte, dass die Eingabeverarbeitung von der Spielschleife getrennt ist. Die Event-Listener setzen nur Flags, und die Update-Funktion liest diese Flags. Diese Entkopplung ist wichtig, weil Tastatur-Events zu unvorhersehbaren Zeitpunkten feuern und deine Spiellogik mit vorhersehbarer Rate laufen soll.</p>
<h2>Sprites laden und zeichnen</h2>
<pre><code class="language-js">const sprites = {}

async function loadSprite(name, url) {
  return new Promise((resolve) =&gt; {
    const img = new Image()
    img.onload = () =&gt; {
      sprites[name] = img
      resolve(img)
    }
    img.src = url
  })
}

function drawSprite(name, x, y, width, height) {
  const sprite = sprites[name]
  if (sprite) {
    ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
  }
}
</code></pre>
<p>Bilder laden asynchron, du musst also auf sie warten, bevor dein Spiel startet. Ein gängiges Muster ist, alle Sprites in einer Funktion <code>loadAssets</code> zu laden, die ein Promise zurückgibt, und die Spielschleife erst danach zu starten.</p>
<p>Die Funktion <code>drawSprite</code> prüft vor dem Zeichnen, ob das Sprite existiert. Das verhindert Fehler, wenn du etwas zeichnen willst, das noch nicht geladen ist.</p>
<h2>Animierte Sprites</h2>
<pre><code class="language-js">class AnimatedSprite {
  constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
    this.image = image
    this.frameWidth = frameWidth
    this.frameHeight = frameHeight
    this.frameCount = frameCount
    this.frameDuration = 1 / fps
    this.currentFrame = 0
    this.elapsed = 0
  }
  
  update(dt) {
    this.elapsed += dt
    if (this.elapsed &gt;= this.frameDuration) {
      this.currentFrame = (this.currentFrame + 1) % this.frameCount
      this.elapsed = 0
    }
  }
  
  draw(ctx, x, y) {
    ctx.drawImage(
      this.image,
      this.currentFrame * this.frameWidth, 0,
      this.frameWidth, this.frameHeight,
      x, y,
      this.frameWidth, this.frameHeight
    )
  }
}
</code></pre>
<p>Diese Klasse geht davon aus, dass dein Sprite-Sheet die Frames horizontal in einer Reihe anordnet. Jedes Frame hat dieselbe Größe. Die Methode <code>update</code> schaltet anhand der vergangenen Zeit durch die Frames, und <code>draw</code> nutzt die neunargumentige Variante von <code>drawImage</code>, um genau das aktuelle Frame auszuschneiden.</p>
<p>So würdest du sie nutzen:</p>
<pre><code class="language-js">const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// In update:
playerWalk.update(dt)

// In render:
playerWalk.draw(ctx, player.x, player.y)
</code></pre>
<h2>Kollisionserkennung</h2>
<pre><code class="language-js">function rectCollision(a, b) {
  return (
    a.x &lt; b.x + b.width &amp;&amp;
    a.x + a.width &gt; b.x &amp;&amp;
    a.y &lt; b.y + b.height &amp;&amp;
    a.y + a.height &gt; b.y
  )
}

function circleCollision(a, b) {
  const dx = a.x - b.x
  const dy = a.y - b.y
  const distance = Math.sqrt(dx * dx + dy * dy)
  return distance &lt; a.radius + b.radius
}

// In update:
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}
</code></pre>
<p>Rechteck-Kollision (auch AABB genannt, für achsenparallele Bounding Box) ist die einfachste und schnellste Variante. Nimm sie für die meisten Fälle. Kreis-Kollision passt besser zu runden Objekten oder wenn Kollisionen großzügiger wirken sollen.</p>
<p>Bei wenigen Entitäten ist es in Ordnung, jedes Paar zu prüfen. Bei Hunderten willst du räumliche Unterteilung (Quadtrees oder ein Gitter), aber das ist ein Thema für ein anderes Tutorial.</p>
<h2>Eine einfache Kamera</h2>
<pre><code class="language-js">const camera = {
  x: 0,
  y: 0,
  
  follow(target) {
    this.x = target.x - canvas.width / 2
    this.y = target.y - canvas.height / 2
  }
}

function render() {
  ctx.save()
  ctx.translate(-camera.x, -camera.y)
  
  // Draw world
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // Draw UI (not affected by camera)
  drawUI()
}
</code></pre>
<p>Der Kniff sind <code>ctx.save()</code> und <code>ctx.restore()</code>. Du sicherst den aktuellen Transformationszustand, wendest mit <code>translate</code> den Kameraversatz an, zeichnest alles im Weltraum und stellst danach wieder her, damit UI-Elemente von der Kamera unberührt bleiben.</p>
<p>Für weichere Kamerabewegung kannst du zum Ziel hin lerpen, statt hart darauf zu springen. Etwas wie <code>this.x += (targetX - this.x) * 0.1</code> erzeugt diesen angenehmen Nachziehereffekt.</p>
<h2>Das Spiel pausieren</h2>
<pre><code class="language-js">let paused = false

window.addEventListener('keydown', (e) =&gt; {
  if (e.code === 'Escape') {
    paused = !paused
  }
})

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  if (!paused) {
    accumulator += frameTime
    while (accumulator &gt;= FIXED_DT) {
      update(FIXED_DT)
      accumulator -= FIXED_DT
    }
  }
  
  render()
  
  if (paused) {
    renderPauseOverlay()
  }
  
  requestAnimationFrame(gameLoop)
}

function renderPauseOverlay() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#fff'
  ctx.font = '32px sans-serif'
  ctx.textAlign = 'center'
  ctx.fillText('PAUSED', canvas.width / 2, canvas.height / 2)
}
</code></pre>
<p>Die zentrale Einsicht ist, dass du im pausierten Zustand keine Zeit mehr ansammelst, aber weiter renderst. So erscheint das Pause-Overlay sofort und das Spiel bleibt für die Eingabe zum Fortsetzen empfänglich.</p>
<p>Du kannst dieses Muster erweitern, um mehrere Spielzustände wie Menüs, Zwischensequenzen und Game-Over-Bildschirme abzudecken. Prüf einfach den aktuellen Zustand und verzweige zu unterschiedlichen Update- und Render-Funktionen.</p>
<h2>Typische Fehler</h2>
<p>Ein paar Dinge, über die ich Leute stolpern sehe:</p>
<p><strong>Das Canvas nicht leeren.</strong> Rufst du am Anfang von render kein <code>clearRect</code> auf, zeichnest du über das vorherige Frame und bekommst Schlieren.</p>
<p><strong>Die Delta-Zeit nicht deckeln.</strong> Wechselt jemand den Tab und kommt zurück, kann eine riesige Delta-Zeit deine Physik zerlegen oder das Spiel sekundenlang &quot;aufholen&quot; lassen.</p>
<p><strong>Zeichnen, bevor Bilder geladen sind.</strong> Canvas ignoriert <code>drawImage</code>-Aufrufe mit nicht geladenen Bildern stillschweigend. Deine Sprites erscheinen einfach nicht, ohne Fehlermeldung.</p>
<p><strong><code>setInterval</code> für die Schleife nutzen.</strong> Es synchronisiert sich nicht mit dem Display und pausiert nicht, wenn der Tab im Hintergrund ist. Nimm <code>requestAnimationFrame</code>.</p>
<p><strong>Welt- und Bildschirmkoordinaten vermischen.</strong> Sobald du eine Kamera ergänzt, behalte im Blick, ob eine Position im Welt- oder im Bildschirmraum liegt. Mausklicks kommen im Bildschirmraum und müssen umgerechnet werden.</p>
<h2>Wie es weitergeht</h2>
<p>Dieses Tutorial deckt die Grundlagen ab. Wenn dir diese Muster vertraut sind, lohnt sich der Blick auf:</p>
<p><a href="/de/tutorials/responsive-game-canvas">Responsives Spiel-Canvas</a> für unterschiedliche Bildschirmgrößen und High-DPI-Displays.</p>
<p><a href="/de/tutorials/game-input-handling">Eingaben in Spielen</a> für Touch-Steuerung, Gamepads und ausgefeiltere Eingabesysteme.</p>
<p><a href="/de/tutorials/pixel-art-rendering">Pixel-Art-Rendering</a> für knackige Pixelgrafik ohne Unschärfe.</p>
<p><a href="/de/tutorials/game-physics-libraries">Physik-Bibliotheken für Spiele</a>, um deiner Spielschleife Starrkörperphysik, Kollisionen und Gelenke hinzuzufügen.</p>
<p>Wenn du zwischen Canvas 2D, WebGL und WebGPU für dein Projekt schwankst, schlüsselt der Leitfaden <a href="/de/guides/web-games-stack-2026">Tech-Stack für Web-Spiele 2026</a> die Abwägungen auf. <a href="/de/guides/web-game-engines-comparison">Web-Game-Engines im Vergleich</a> behandelt Frameworks, die dir die Spielschleife abnehmen.</p>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame">MDN: requestAnimationFrame</a> — Referenz zur Browser-API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API">MDN: Canvas API</a> — vollständige Canvas-2D-Dokumentation</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent">MDN: KeyboardEvent</a> — Tastencodes und Event-Eigenschaften</li>
<li><a href="https://gafferongames.com/post/fix_your_timestep/">Fix Your Timestep!</a> — Glenn Fiedlers Klassiker über Spielschleifen mit festem Zeitschritt</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Wasm-Threads (SharedArrayBuffer) mit COOP/COEP aktivieren]]></title>
            <link>https://app.cinevva.com/de/tutorials/coop-coep-sharedarraybuffer</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/coop-coep-sharedarraybuffer</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Eine praktische Copy-paste-Anleitung zur Cross-Origin-Isolation: nötige Header, typische Stolperfallen und Server-Konfigurationen für SharedArrayBuffer und WebAssembly-Threads. Mit kostenlosem Checker, der prüft, ob eine URL cross-origin-isoliert ist.]]></description>
            <content:encoded><![CDATA[<h1>Wasm-Threads (SharedArrayBuffer) mit COOP/COEP aktivieren</h1>
<p>Wenn dein Web-Spiel (oder ein Engine-Export) <strong>WebAssembly-Threads</strong> nutzt, hängt es meist an <code>SharedArrayBuffer</code>. Moderne Browser verlangen dafür <strong>Cross-Origin-Isolation</strong>.</p>
<p><img src="/img/cross-origin-isolation.svg" alt="Diagramm: Header für Cross-Origin-Isolation"></p>
<h2>1) Kurzer Check: bist du isoliert?</h2>
<p>Öffne die DevTools und führ das aus:</p>
<pre><code class="language-js">self.crossOriginIsolated
</code></pre>
<p>Ist das Ergebnis <code>true</code>, bist du im richtigen Sicherheitszustand für <code>SharedArrayBuffer</code>.</p>
<p>Offizielle Referenz:</p>
<ul>
<li><code>https://developer.mozilla.org/docs/Web/API/Window/crossOriginIsolated</code></li>
</ul>
<h2>2) Die zwei Header, die du brauchst</h2>
<p>Liefere diese Response-Header für dein HTML aus (und in der Regel für die ganze App):</p>
<pre><code class="language-http">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<p>Offizielle Referenzen:</p>
<ul>
<li><code>https://web.dev/articles/why-coop-coep</code></li>
<li><code>https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer</code></li>
<li><code>https://developer.chrome.com/blog/enabling-shared-array-buffer/</code></li>
</ul>
<h2>3) Grund Nummer 1, warum es „immer noch nicht geht&quot;: Subressourcen</h2>
<p>Mit <code>COEP: require-corp</code> kann deine Seite Cross-Origin-Ressourcen nicht mehr frei einbinden.</p>
<p>Lädst du ein Script, Bild, Worker, Video oder Ähnliches von einer anderen Origin, muss diese Ressource ausdrücklich zustimmen, über:</p>
<ul>
<li>CORS (<code>Access-Control-Allow-Origin</code>) <strong>oder</strong></li>
<li>CORP (<code>Cross-Origin-Resource-Policy</code>)</li>
</ul>
<p>Offizielle Referenz:</p>
<ul>
<li><code>https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cross-Origin_Resource_Policy</code></li>
</ul>
<h2>4) Konfigurationen zum Kopieren</h2>
<h3>Nginx</h3>
<pre><code class="language-nginx">add_header Cross-Origin-Opener-Policy &quot;same-origin&quot; always;
add_header Cross-Origin-Embedder-Policy &quot;require-corp&quot; always;
</code></pre>
<h3>Node / Express</h3>
<pre><code class="language-js">app.use((req, res, next) =&gt; {
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
  res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
  next()
})
</code></pre>
<h3>Cloudflare Pages (<code>_headers</code>)</h3>
<pre><code class="language-txt">/*
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<h2>5) Checkliste zur Fehlersuche</h2>
<ul>
<li>Prüfe im Netzwerk-Panel, ob die Header auf der <strong>Dokument</strong>-Response wirklich ankommen.</li>
<li>Prüfe <code>self.crossOriginIsolated</code>.</li>
<li>Halte nach blockierten Requests Ausschau (die DevTools melden typischerweise „blocked … by COEP&quot;).</li>
<li>Entferne Drittanbieter-Scripts ohne CORS/CORP oder hoste sie selbst.</li>
</ul>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/guides/web-games-stack-2026">Tech-Stack für Web-Spiele 2026</a></li>
<li><a href="/de/tutorials/web-workers-game-logic">Web Workers für Spiellogik</a> — Worker nutzen, die von SharedArrayBuffer profitieren</li>
<li><a href="/de/tutorials/websocket-multiplayer-basics">WebSocket-Multiplayer-Grundlagen</a> — Multiplayer-Spiele, die Cross-Origin-Isolation brauchen</li>
<li><a href="/de/tutorials/iframe-embedding-games">Spiele per Iframe einbetten</a> — wie COOP/COEP-Header Iframe-Embeds beeinflussen</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy">MDN: Cross-Origin-Opener-Policy</a> — Referenz zum COOP-Header</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Embedder-Policy">MDN: Cross-Origin-Embedder-Policy</a> — Referenz zum COEP-Header</li>
<li><a href="https://web.dev/articles/cross-origin-isolation-guide">web.dev: Making your website cross-origin isolated</a> — praktischer Deployment-Leitfaden</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Eingaben in Spielen (Tastatur, Maus, Touch, Gamepad)]]></title>
            <link>https://app.cinevva.com/de/tutorials/game-input-handling</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/game-input-handling</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Vereinheitlichte Eingabeverarbeitung für Web-Spiele: Tastatur, Maus, Touch und Gamepad mit praxisnahen Codemustern.]]></description>
            <content:encoded><![CDATA[<h1>Eingaben in Spielen</h1>
<p>Gute Eingabeverarbeitung entscheidet über ein Spiel. Träge Steuerung, verschluckte Eingaben oder verwirrende Tastenbelegungen frustrieren Spieler, bevor sie überhaupt den Inhalt deines Spiels sehen. Dieses Tutorial zeigt, wie du Tastatur, Maus, Touch und Gamepad einheitlich behandelst.</p>
<p><strong>Probier es zuerst aus:</strong></p>
<InputDemo />
<h2>Das Muster des Eingabezustands</h2>
<p>Der naive Ansatz ist, direkt auf Events zu reagieren: Wenn der Nutzer eine Taste drückt, mach etwas. Das macht Ärger. Events feuern zu unvorhersehbaren Zeitpunkten, während deine Spielschleife mit fester Rate läuft. Bewegst du den Spieler im keydown-Handler, hängt die Bewegungsgeschwindigkeit von der Tastenwiederholrate ab, und die unterscheidet sich je nach Betriebssystem.</p>
<p>Pflege stattdessen ein Objekt mit dem Eingabezustand, das deine Spielschleife liest:</p>
<pre><code class="language-js">const input = {
  left: false,
  right: false,
  up: false,
  down: false,
  action: false,
  pointer: { x: 0, y: 0, down: false },
}
</code></pre>
<p>Event-Handler setzen diese Flags. Die Spielschleife liest sie. Diese Entkopplung macht alles einfacher: Deiner Update-Logik ist es egal, ob die Eingabe von Tastatur, Touch oder Gamepad kam.</p>
<h2>Tastatur</h2>
<p>Nutz <code>e.code</code> statt <code>e.key</code>, weil es sich auf die physische Tastenposition bezieht. WASD funktioniert damit auch auf Nicht-QWERTY-Layouts korrekt.</p>
<pre><code class="language-js">const keyMap = {
  ArrowLeft: 'left',
  ArrowRight: 'right',
  ArrowUp: 'up',
  ArrowDown: 'down',
  KeyA: 'left',
  KeyD: 'right',
  KeyW: 'up',
  KeyS: 'down',
  Space: 'action',
}

window.addEventListener('keydown', (e) =&gt; {
  const action = keyMap[e.code]
  if (action) {
    input[action] = true
    e.preventDefault()
  }
})

window.addEventListener('keyup', (e) =&gt; {
  const action = keyMap[e.code]
  if (action) input[action] = false
})
</code></pre>
<p>Das <code>e.preventDefault()</code> verhindert, dass die Pfeiltasten die Seite scrollen. Ruf es nur für Tasten auf, die du selbst behandelst.</p>
<h2>Maus</h2>
<p>Mauskoordinaten kommen im Bildschirmraum an. Du musst sie in den Canvas-Raum umrechnen:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')

canvas.addEventListener('mousemove', (e) =&gt; {
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = e.clientX - rect.left
  input.pointer.y = e.clientY - rect.top
})

canvas.addEventListener('mousedown', () =&gt; {
  input.pointer.down = true
  input.action = true
})

canvas.addEventListener('mouseup', () =&gt; {
  input.pointer.down = false
  input.action = false
})
</code></pre>
<p>Ist dein Canvas skaliert (siehe das Tutorial zum responsiven Canvas), musst du durch den Skalierungsfaktor teilen, um Spielkoordinaten zu bekommen.</p>
<h2>Touch</h2>
<p>Touch funktioniert ähnlich wie die Maus, du brauchst aber <code>e.preventDefault()</code>, damit der Browser nicht scrollt oder zoomt:</p>
<pre><code class="language-js">canvas.addEventListener('touchstart', (e) =&gt; {
  e.preventDefault()
  const touch = e.touches[0]
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = touch.clientX - rect.left
  input.pointer.y = touch.clientY - rect.top
  input.pointer.down = true
  input.action = true
}, { passive: false })

canvas.addEventListener('touchmove', (e) =&gt; {
  e.preventDefault()
  const touch = e.touches[0]
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = touch.clientX - rect.left
  input.pointer.y = touch.clientY - rect.top
}, { passive: false })

canvas.addEventListener('touchend', () =&gt; {
  input.pointer.down = false
  input.action = false
})
</code></pre>
<p>Das <code>{ passive: false }</code> ist nötig, weil moderne Browser Touch-Listener aus Performancegründen standardmäßig als passiv behandeln. Ohne das wirkt <code>preventDefault</code> nicht.</p>
<h2>Virtuelles Steuerkreuz für Mobilgeräte</h2>
<p>Für Spiele, die auf Mobilgeräten Richtungssteuerung brauchen, ergänzt du Bildschirmtasten:</p>
<pre><code class="language-js">function createVirtualDpad(container) {
  const dpad = document.createElement('div')
  dpad.className = 'virtual-dpad'
  dpad.innerHTML = `
    &lt;button data-dir=&quot;up&quot;&gt;↑&lt;/button&gt;
    &lt;button data-dir=&quot;left&quot;&gt;←&lt;/button&gt;
    &lt;button data-dir=&quot;right&quot;&gt;→&lt;/button&gt;
    &lt;button data-dir=&quot;down&quot;&gt;↓&lt;/button&gt;
  `
  
  dpad.querySelectorAll('button').forEach(btn =&gt; {
    const dir = btn.dataset.dir
    btn.addEventListener('touchstart', (e) =&gt; {
      e.preventDefault()
      input[dir] = true
    })
    btn.addEventListener('touchend', () =&gt; input[dir] = false)
  })
  
  container.appendChild(dpad)
}
</code></pre>
<p>Gestalte diese Tasten groß genug für Daumen (mindestens 44x44 Pixel) und platziere sie in den unteren Ecken.</p>
<h2>Gamepad</h2>
<p>Anders als bei Tastatur und Maus kommt der Gamepad-Zustand nicht als Event. Du musst ihn in jedem Frame abfragen:</p>
<pre><code class="language-js">function pollGamepads() {
  const gamepads = navigator.getGamepads()
  const gp = gamepads[0]
  
  if (gp) {
    // D-pad or left stick
    input.left = gp.buttons[14]?.pressed || gp.axes[0] &lt; -0.5
    input.right = gp.buttons[15]?.pressed || gp.axes[0] &gt; 0.5
    input.up = gp.buttons[12]?.pressed || gp.axes[1] &lt; -0.5
    input.down = gp.buttons[13]?.pressed || gp.axes[1] &gt; 0.5
    
    // A button (standard mapping)
    input.action = gp.buttons[0]?.pressed
  }
}
</code></pre>
<p>Ruf <code>pollGamepads()</code> am Anfang deiner Update-Funktion auf. Die Schwelle von 0,5 auf den Analogsticks erzeugt eine Totzone, damit kleine Stickbewegungen nicht als Eingabe zählen.</p>
<p>Du kannst erkennen, wenn Gamepads verbunden und getrennt werden:</p>
<pre><code class="language-js">window.addEventListener('gamepadconnected', (e) =&gt; {
  console.log('Gamepad connected:', e.gamepad.id)
})

window.addEventListener('gamepaddisconnected', (e) =&gt; {
  console.log('Gamepad disconnected:', e.gamepad.id)
})
</code></pre>
<h2>Eingaben in deiner Spielschleife nutzen</h2>
<p>Jetzt liest deine Update-Funktion einfach den Eingabezustand:</p>
<pre><code class="language-js">function update(dt) {
  pollGamepads()
  
  if (input.left) player.x -= player.speed * dt
  if (input.right) player.x += player.speed * dt
  if (input.up) player.y -= player.speed * dt
  if (input.down) player.y += player.speed * dt
  
  if (input.action &amp;&amp; player.canShoot) {
    player.shoot()
  }
}
</code></pre>
<p>Das funktioniert unabhängig davon, ob die Eingabe von Tastatur, Touch oder Gamepad kam. Die Spiellogik muss es nicht wissen.</p>
<h2>Gerade gedrückt gegen gehalten</h2>
<p>Manchmal willst du erkennen, ob eine Taste in diesem Frame gerade gedrückt wurde, und nicht, ob sie gerade gehalten wird. Springen ist ein gutes Beispiel: Du willst einen Sprung pro Druck, kein Dauerspringen, solange die Taste gehalten wird.</p>
<pre><code class="language-js">const inputPrev = { ...input }

function wasJustPressed(action) {
  return input[action] &amp;&amp; !inputPrev[action]
}

function endFrame() {
  Object.assign(inputPrev, input)
}
</code></pre>
<p>Ruf <code>endFrame()</code> am Ende deiner Update-Schleife auf. Nutz dann <code>wasJustPressed('action')</code> statt <code>input.action</code> für einmalige Aktionen.</p>
<h2>Fokusverlust</h2>
<p>Verliert das Browserfenster den Fokus, hören Tastatur-Events auf zu feuern. Hielt der Spieler gerade eine Taste, bleibt sie hängen:</p>
<pre><code class="language-js">window.addEventListener('blur', () =&gt; {
  input.left = input.right = input.up = input.down = input.action = false
})
</code></pre>
<p>Ohne das läuft die Figur weiter, wenn der Spieler per Alt-Tab wegwechselt.</p>
<h2>Best Practices</h2>
<p>Unterstütze WASD und Pfeiltasten, denn Spieler haben Vorlieben. Erlaube Neubelegung und leg die Konfiguration in localStorage ab. Biete auf Mobilgeräten Touch-Steuerung an. Teste mit echten Gamepads, weil Xbox-, PlayStation- und generische Controller sich alle leicht unterschiedlich verhalten. Und behandle Fokusverlust immer sauber.</p>
<h2>Mehr Ressourcen</h2>
<p><a href="/de/tutorials/gamepad-api">Gamepad-API im Detail</a> behandelt fortgeschrittene Gamepad-Funktionen wie Vibration und mehrere Spieler.</p>
<p><a href="/de/tutorials/mobile-friendly-web-games">Mobilfreundliche Web-Spiele</a> geht ausführlicher auf Touch-Eingaben ein.</p>
<p><a href="/de/tutorials/pointer-lock-fps">Pointer Lock für Shooter</a> zeigt, wie du die Maus für Ego-Steuerung einfängst.</p>
<p><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a> zeigt das Muster des Eingabezustands eingebaut in eine vollständige Spielschleife.</p>
<p><a href="/de/tutorials/websocket-multiplayer-basics">WebSocket-Multiplayer-Grundlagen</a> behandelt das Synchronisieren von Eingaben über vernetzte Spieler hinweg.</p>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code">MDN: KeyboardEvent.code</a> — physische Tastenwerte für die Eingabezuordnung</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events">MDN: Pointer events</a> — vereinheitlichte Maus- und Touch-Events</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN: Gamepad API</a> — Controller-Unterstützung im Browser</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN: Touch events</a> — Multi-Touch auf Mobilgeräten</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Spielphysik für Web-Spiele]]></title>
            <link>https://app.cinevva.com/de/tutorials/game-physics-libraries</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/game-physics-libraries</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Physik-Engines für Web-Spiele im Vergleich: Rapier, Cannon-es, Ammo.js, Matter.js, Planck.js. Leistungsvergleiche, Codebeispiele und wann welche passt.]]></description>
            <content:encoded><![CDATA[<h1>Spielphysik für Web-Spiele</h1>
<p>Physiksimulation erweckt Spiele zum Leben: fallende Objekte, springende Bälle, Ragdolls, Fahrzeuge und zerstörbare Umgebungen. Dieser Leitfaden behandelt die wichtigsten Physik-Bibliotheken für Web-Spiele, mit echten Codebeispielen und ehrlichen Vor- und Nachteilen.</p>
<h2>Auf einen Blick</h2>
<table>
<thead>
<tr>
<th>Engine</th>
<th style="text-align:center">Dimension</th>
<th style="text-align:center">Leistung</th>
<th style="text-align:center">Größe</th>
<th style="text-align:center">Schwierigkeit</th>
<th style="text-align:center">Soft Bodies</th>
<th style="text-align:center">Fahrzeuge</th>
<th style="text-align:center">CCD</th>
<th style="text-align:center">Determinismus</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Rapier</strong></td>
<td style="text-align:center">2D/3D</td>
<td style="text-align:center">⭐⭐⭐⭐⭐</td>
<td style="text-align:center">1,4 MB</td>
<td style="text-align:center">mittel</td>
<td style="text-align:center">—</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
</tr>
<tr>
<td><strong>Cannon-es</strong></td>
<td style="text-align:center">3D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">150 KB</td>
<td style="text-align:center">leicht</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
</tr>
<tr>
<td><strong>Ammo.js</strong></td>
<td style="text-align:center">3D</td>
<td style="text-align:center">⭐⭐⭐⭐⭐</td>
<td style="text-align:center">1-2 MB</td>
<td style="text-align:center">schwer</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">⚠️</td>
</tr>
<tr>
<td><strong>Jolt</strong></td>
<td style="text-align:center">3D</td>
<td style="text-align:center">⭐⭐⭐⭐⭐</td>
<td style="text-align:center">—</td>
<td style="text-align:center">schwer</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
</tr>
<tr>
<td><strong>Oimo.js</strong></td>
<td style="text-align:center">3D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">100 KB</td>
<td style="text-align:center">leicht</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
</tr>
<tr>
<td><strong>Matter.js</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">80 KB</td>
<td style="text-align:center">leicht</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">⚠️</td>
</tr>
<tr>
<td><strong>Planck.js</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐⭐</td>
<td style="text-align:center">120 KB</td>
<td style="text-align:center">mittel</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">⚠️</td>
</tr>
<tr>
<td><strong>p2-es</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">100 KB</td>
<td style="text-align:center">mittel</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
</tr>
<tr>
<td><strong>Box2D WASM</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐⭐⭐</td>
<td style="text-align:center">300 KB</td>
<td style="text-align:center">schwer</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
</tr>
</tbody>
</table>
<p><strong>Legende:</strong></p>
<ul>
<li>CCD = Continuous Collision Detection, verhindert, dass schnelle Objekte durch Wände tunneln</li>
<li>⚠️ Determinismus = mit festem Zeitschritt möglich, aber plattformübergreifend nicht garantiert</li>
</ul>
<h3>Kurze Empfehlungen</h3>
<table>
<thead>
<tr>
<th>Deine Lage</th>
<th>Beste Wahl</th>
</tr>
</thead>
<tbody>
<tr>
<td>3D-Spiel in Produktion</td>
<td><strong>Rapier</strong> — beste Leistung, moderne API, aktive Entwicklung</td>
</tr>
<tr>
<td>Lernen und Prototyp</td>
<td><strong>Cannon-es</strong> (3D) oder <strong>Matter.js</strong> (2D) — einfache APIs, leichtes Debugging</td>
</tr>
<tr>
<td>Fahrzeugphysik</td>
<td><strong>Rapier</strong> oder <strong>Ammo.js</strong> — beide haben Raycast-Fahrzeugcontroller</td>
</tr>
<tr>
<td>Soft Bodies, Stoff, Seile</td>
<td><strong>Ammo.js</strong> oder <strong>Jolt (JoltPhysics.js)</strong> — beide unterstützen Stoff und Soft Bodies, Jolts WASM-Port wird aktiver gepflegt</td>
</tr>
<tr>
<td>Präziser 2D-Plattformer</td>
<td><strong>Planck.js</strong> — Box2D-Algorithmen, deterministisch mit festem Zeitschritt</td>
</tr>
<tr>
<td>Maximale 2D-Leistung</td>
<td><strong>Box2D WASM</strong> — native Geschwindigkeit im Browser</td>
</tr>
<tr>
<td>Kleinste Bundle-Größe</td>
<td><strong>Oimo.js</strong> (3D) oder <strong>Matter.js</strong> (2D)</td>
</tr>
</tbody>
</table>
<hr>
<h2>Ausführlicher Vergleich</h2>
<h3>3D-Physik-Engines</h3>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Sprache</th>
<th>Größe</th>
<th>Leistung</th>
<th>Am besten für</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Rapier</strong></td>
<td>Rust/WASM</td>
<td>~1,4 MB</td>
<td>ausgezeichnet</td>
<td>Spiele in Produktion, komplexe Simulationen</td>
</tr>
<tr>
<td><strong>Cannon-es</strong></td>
<td>JavaScript</td>
<td>~150 KB</td>
<td>gut</td>
<td>Prototypen, einfache Spiele</td>
</tr>
<tr>
<td><strong>Ammo.js</strong></td>
<td>C++/WASM</td>
<td>~1-2 MB</td>
<td>ausgezeichnet</td>
<td>AAA-Funktionen, Soft Bodies, Fahrzeuge</td>
</tr>
<tr>
<td><strong>Jolt</strong></td>
<td>C++/WASM</td>
<td>WASM</td>
<td>ausgezeichnet</td>
<td>AAA-Funktionen mit aktiver Pflege</td>
</tr>
<tr>
<td><strong>Oimo.js</strong></td>
<td>JavaScript</td>
<td>~100 KB</td>
<td>gut</td>
<td>einfache Spiele, schnelle Prototypen</td>
</tr>
</tbody>
</table>
<h3>2D-Physik-Engines</h3>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Sprache</th>
<th>Größe</th>
<th>Leistung</th>
<th>Am besten für</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Matter.js</strong></td>
<td>JavaScript</td>
<td>~80 KB</td>
<td>gut</td>
<td>visuelle Spiele, Prototypen</td>
</tr>
<tr>
<td><strong>Planck.js</strong></td>
<td>JavaScript</td>
<td>~120 KB</td>
<td>gut</td>
<td>Plattformer, präzise Physik</td>
</tr>
<tr>
<td><strong>p2-es</strong></td>
<td>JavaScript</td>
<td>~100 KB</td>
<td>gut</td>
<td>Constraints, Mechanismen</td>
</tr>
<tr>
<td><strong>Box2D WASM</strong></td>
<td>C++/WASM</td>
<td>~300 KB</td>
<td>ausgezeichnet</td>
<td>viele Körper</td>
</tr>
</tbody>
</table>
<hr>
<h2>3D-Physik-Engines</h2>
<h3>Rapier — die moderne Wahl</h3>
<p>Rapier ist eine Physik-Engine in Rust mit JavaScript- und WASM-Bindings. Sie ist 2025 und 2026 die leistungsstärkste Option für Web-Spiele, mit 2- bis 5-facher Beschleunigung gegenüber der Version von 2024.</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">npm install @dimforge/rapier3d
# Or with SIMD (faster, requires modern browsers):
npm install @dimforge/rapier3d-simd
</code></pre>
<p><strong>Grundaufbau:</strong></p>
<pre><code class="language-js">import RAPIER from '@dimforge/rapier3d'

// Initialize (async required for WASM)
await RAPIER.init()

// Create world with gravity
const gravity = { x: 0, y: -9.81, z: 0 }
const world = new RAPIER.World(gravity)

// Create ground (static body)
const groundDesc = RAPIER.RigidBodyDesc.fixed()
const groundBody = world.createRigidBody(groundDesc)
const groundCollider = RAPIER.ColliderDesc.cuboid(50, 0.1, 50)
world.createCollider(groundCollider, groundBody)

// Create falling box (dynamic body)
const boxDesc = RAPIER.RigidBodyDesc.dynamic()
  .setTranslation(0, 10, 0)
const boxBody = world.createRigidBody(boxDesc)
const boxCollider = RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5)
  .setDensity(1.0)
  .setRestitution(0.5)
world.createCollider(boxCollider, boxBody)
</code></pre>
<p><strong>Einbindung in die Spielschleife:</strong></p>
<pre><code class="language-js">const FIXED_TIMESTEP = 1 / 60

function physicsStep() {
  world.step()
}

function gameLoop() {
  physicsStep()
  
  // Sync render objects with physics
  const position = boxBody.translation()
  const rotation = boxBody.rotation()
  
  // Update your Three.js/Babylon mesh
  mesh.position.set(position.x, position.y, position.z)
  mesh.quaternion.set(rotation.x, rotation.y, rotation.z, rotation.w)
  
  requestAnimationFrame(gameLoop)
}
</code></pre>
<p><strong>Kollisionserkennung:</strong></p>
<pre><code class="language-js">// Event-based collision detection
world.contactPairsWith(boxCollider, (otherCollider) =&gt; {
  console.log('Box is touching:', otherCollider)
})

// Ray casting
const ray = new RAPIER.Ray({ x: 0, y: 10, z: 0 }, { x: 0, y: -1, z: 0 })
const hit = world.castRay(ray, 100, true)
if (hit) {
  const hitPoint = ray.pointAt(hit.timeOfImpact)
  console.log('Hit at:', hitPoint)
}
</code></pre>
<p><strong>Gelenke:</strong></p>
<pre><code class="language-js">// Create a hinge joint (door)
const jointData = RAPIER.JointData.revolute(
  { x: 0, y: 0, z: 0 },  // Anchor on body1
  { x: -1, y: 0, z: 0 }, // Anchor on body2
  { x: 0, y: 1, z: 0 }   // Rotation axis
)
world.createImpulseJoint(jointData, body1, body2, true)
</code></pre>
<p><strong>Fahrzeugcontroller:</strong></p>
<pre><code class="language-js">// Create chassis rigid body
const chassisDesc = RAPIER.RigidBodyDesc.dynamic()
  .setTranslation(0, 2, 0)
const chassis = world.createRigidBody(chassisDesc)
const chassisCollider = RAPIER.ColliderDesc.cuboid(1, 0.5, 2)
  .setDensity(100)
world.createCollider(chassisCollider, chassis)

// Create vehicle controller
const vehicle = world.createVehicleController(chassis)

// Add wheels (front-left, front-right, rear-left, rear-right)
const suspensionRestLength = 0.3
const wheelRadius = 0.4

// Front wheels (steering)
vehicle.addWheel(
  { x: -0.8, y: 0, z: 1.2 },   // Connection point
  { x: 0, y: -1, z: 0 },       // Suspension direction
  { x: -1, y: 0, z: 0 },       // Axle direction
  suspensionRestLength,
  wheelRadius
)
vehicle.addWheel(
  { x: 0.8, y: 0, z: 1.2 },
  { x: 0, y: -1, z: 0 },
  { x: -1, y: 0, z: 0 },
  suspensionRestLength,
  wheelRadius
)

// Rear wheels (drive)
vehicle.addWheel(
  { x: -0.8, y: 0, z: -1.2 },
  { x: 0, y: -1, z: 0 },
  { x: -1, y: 0, z: 0 },
  suspensionRestLength,
  wheelRadius
)
vehicle.addWheel(
  { x: 0.8, y: 0, z: -1.2 },
  { x: 0, y: -1, z: 0 },
  { x: -1, y: 0, z: 0 },
  suspensionRestLength,
  wheelRadius
)

// Configure suspension for all wheels
for (let i = 0; i &lt; 4; i++) {
  vehicle.setWheelSuspensionStiffness(i, 30)
  vehicle.setWheelSuspensionCompression(i, 4.4)
  vehicle.setWheelSuspensionRelaxation(i, 2.3)
  vehicle.setWheelMaxSuspensionTravel(i, 0.5)
  vehicle.setWheelFrictionSlip(i, 2)
}

// In game loop
function updateVehicle(steering, engineForce, brakeForce) {
  // Steering (front wheels only)
  vehicle.setWheelSteering(0, steering)
  vehicle.setWheelSteering(1, steering)
  
  // Engine (rear wheels)
  vehicle.setWheelEngineForce(2, engineForce)
  vehicle.setWheelEngineForce(3, engineForce)
  
  // Brakes (all wheels)
  for (let i = 0; i &lt; 4; i++) {
    vehicle.setWheelBrake(i, brakeForce)
  }
  
  // Update vehicle physics
  vehicle.updateVehicle(world.timestep)
}
</code></pre>
<p><strong>Vorteile:</strong></p>
<ul>
<li>Beste Leistung (WASM plus SIMD)</li>
<li>Sehr genaue Kollisionserkennung</li>
<li>Plattformübergreifender Determinismus (gleiche Eingaben ergeben gleiche Ausgaben)</li>
<li>Aktive Entwicklung, moderne API</li>
<li>Continuous Collision Detection, kein Tunneln</li>
<li>Charakter- und Fahrzeugcontroller eingebaut</li>
</ul>
<p><strong>Nachteile:</strong></p>
<ul>
<li>Größeres Bundle (~1,4 MB)</li>
<li>Asynchrone Initialisierung nötig</li>
<li>Komplexere API als reine JS-Alternativen</li>
<li>WASM-Debugging kann heikel sein</li>
</ul>
<hr>
<h3>Cannon-es — einfach und wirkungsvoll</h3>
<p>Cannon-es ist der gepflegte Fork von Cannon.js. Reines JavaScript, leicht verständlich, gut zum Lernen und für Prototypen.</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">npm install cannon-es
</code></pre>
<p><strong>Grundaufbau:</strong></p>
<pre><code class="language-js">import * as CANNON from 'cannon-es'

// Create world
const world = new CANNON.World({
  gravity: new CANNON.Vec3(0, -9.81, 0)
})

// Ground
const groundBody = new CANNON.Body({
  type: CANNON.Body.STATIC,
  shape: new CANNON.Plane()
})
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0)
world.addBody(groundBody)

// Falling sphere
const sphereBody = new CANNON.Body({
  mass: 1,
  shape: new CANNON.Sphere(0.5),
  position: new CANNON.Vec3(0, 10, 0)
})
sphereBody.linearDamping = 0.1
world.addBody(sphereBody)
</code></pre>
<p><strong>Spielschleife:</strong></p>
<pre><code class="language-js">const TIMESTEP = 1 / 60

function animate() {
  world.step(TIMESTEP)
  
  // Sync with Three.js
  mesh.position.copy(sphereBody.position)
  mesh.quaternion.copy(sphereBody.quaternion)
  
  requestAnimationFrame(animate)
}
</code></pre>
<p><strong>Kollisions-Events:</strong></p>
<pre><code class="language-js">sphereBody.addEventListener('collide', (event) =&gt; {
  const contact = event.contact
  const impactVelocity = contact.getImpactVelocityAlongNormal()
  
  if (Math.abs(impactVelocity) &gt; 5) {
    console.log('Hard impact!')
  }
})
</code></pre>
<p><strong>Constraints:</strong></p>
<pre><code class="language-js">// Distance constraint (rope-like)
const constraint = new CANNON.DistanceConstraint(
  bodyA, bodyB, 
  2 // distance
)
world.addConstraint(constraint)

// Hinge constraint
const hinge = new CANNON.HingeConstraint(bodyA, bodyB, {
  pivotA: new CANNON.Vec3(1, 0, 0),
  axisA: new CANNON.Vec3(0, 1, 0),
  pivotB: new CANNON.Vec3(-1, 0, 0),
  axisB: new CANNON.Vec3(0, 1, 0)
})
world.addConstraint(hinge)
</code></pre>
<p><strong>Vorteile:</strong></p>
<ul>
<li>Reines JavaScript, keine WASM-Komplexität</li>
<li>Kleines Bundle (~150 KB)</li>
<li>Leicht zu lernen und zu debuggen</li>
<li>Läuft überall</li>
<li>Gute Three.js-Integration</li>
<li>Gute Dokumentation</li>
</ul>
<p><strong>Nachteile:</strong></p>
<ul>
<li>Langsamer als WASM-Alternativen</li>
<li>Schwächelt bei vielen Körpern (über 100)</li>
<li>Eingeschränkte Trimesh-Unterstützung</li>
<li>Kein eingebautes CCD, Tunneln möglich</li>
<li>Weniger aktive Entwicklung</li>
</ul>
<hr>
<h3>Ammo.js — die volle Kraft von Bullet Physics</h3>
<p>Ammo.js ist die Bullet-Physics-Engine, kompiliert nach WebAssembly. Maximaler Funktionsumfang, inklusive Soft Bodies, Fahrzeugen und fortgeschrittenen Constraints.</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">npm install ammo.js
# Or use from CDN
</code></pre>
<p><strong>Grundaufbau:</strong></p>
<pre><code class="language-js">import Ammo from 'ammo.js'

let physicsWorld

async function initPhysics() {
  await Ammo()
  
  const collisionConfig = new Ammo.btDefaultCollisionConfiguration()
  const dispatcher = new Ammo.btCollisionDispatcher(collisionConfig)
  const broadphase = new Ammo.btDbvtBroadphase()
  const solver = new Ammo.btSequentialImpulseConstraintSolver()
  
  physicsWorld = new Ammo.btDiscreteDynamicsWorld(
    dispatcher, broadphase, solver, collisionConfig
  )
  physicsWorld.setGravity(new Ammo.btVector3(0, -9.81, 0))
}

function createBox(mass, width, height, depth, x, y, z) {
  const transform = new Ammo.btTransform()
  transform.setIdentity()
  transform.setOrigin(new Ammo.btVector3(x, y, z))
  
  const motionState = new Ammo.btDefaultMotionState(transform)
  const shape = new Ammo.btBoxShape(
    new Ammo.btVector3(width / 2, height / 2, depth / 2)
  )
  
  const localInertia = new Ammo.btVector3(0, 0, 0)
  if (mass &gt; 0) {
    shape.calculateLocalInertia(mass, localInertia)
  }
  
  const rbInfo = new Ammo.btRigidBodyConstructionInfo(
    mass, motionState, shape, localInertia
  )
  const body = new Ammo.btRigidBody(rbInfo)
  
  physicsWorld.addRigidBody(body)
  return body
}
</code></pre>
<p><strong>Soft Bodies (Stoff):</strong></p>
<pre><code class="language-js">function createCloth(width, height, segments) {
  const softBodyHelpers = new Ammo.btSoftBodyHelpers()
  
  const corner00 = new Ammo.btVector3(-width/2, height, 0)
  const corner10 = new Ammo.btVector3(width/2, height, 0)
  const corner01 = new Ammo.btVector3(-width/2, 0, 0)
  const corner11 = new Ammo.btVector3(width/2, 0, 0)
  
  const softBody = softBodyHelpers.CreatePatch(
    physicsWorld.getWorldInfo(),
    corner00, corner10, corner01, corner11,
    segments, segments,
    0, true
  )
  
  const sbConfig = softBody.get_m_cfg()
  sbConfig.set_viterations(10)
  sbConfig.set_piterations(10)
  
  softBody.setTotalMass(0.9, false)
  Ammo.castObject(softBody, Ammo.btCollisionObject)
    .getCollisionShape().setMargin(0.05)
  
  physicsWorld.addSoftBody(softBody, 1, -1)
  return softBody
}
</code></pre>
<p><strong>Fahrzeugphysik:</strong></p>
<pre><code class="language-js">function createVehicle(chassisBody) {
  const tuning = new Ammo.btVehicleTuning()
  const rayCaster = new Ammo.btDefaultVehicleRaycaster(physicsWorld)
  const vehicle = new Ammo.btRaycastVehicle(tuning, chassisBody, rayCaster)
  
  vehicle.setCoordinateSystem(0, 1, 2)
  physicsWorld.addAction(vehicle)
  
  // Add wheels
  const wheelRadius = 0.4
  const wheelWidth = 0.3
  const suspensionRestLength = 0.3
  
  const wheelDirectionCS = new Ammo.btVector3(0, -1, 0)
  const wheelAxleCS = new Ammo.btVector3(-1, 0, 0)
  
  function addWheel(isFront, pos) {
    const wheelInfo = vehicle.addWheel(
      pos, wheelDirectionCS, wheelAxleCS,
      suspensionRestLength, wheelRadius, tuning, isFront
    )
    wheelInfo.set_m_suspensionStiffness(20)
    wheelInfo.set_m_wheelsDampingRelaxation(2.3)
    wheelInfo.set_m_wheelsDampingCompression(4.4)
    wheelInfo.set_m_frictionSlip(1000)
    wheelInfo.set_m_rollInfluence(0.1)
  }
  
  addWheel(true, new Ammo.btVector3(1, 0, 1.5))   // Front left
  addWheel(true, new Ammo.btVector3(-1, 0, 1.5))  // Front right
  addWheel(false, new Ammo.btVector3(1, 0, -1.5)) // Rear left
  addWheel(false, new Ammo.btVector3(-1, 0, -1.5))// Rear right
  
  return vehicle
}
</code></pre>
<p><strong>Vorteile:</strong></p>
<ul>
<li>Der vollständigste Funktionsumfang</li>
<li>Soft Bodies, Stoff, Seile</li>
<li>Fortgeschrittene Fahrzeugphysik</li>
<li>Praxiserprobt, in vielen AAA-Spielen im Einsatz</li>
<li>Sehr konfigurierbar</li>
</ul>
<p><strong>Nachteile:</strong></p>
<ul>
<li>Komplexe, geschwätzige API</li>
<li>Großes Bundle</li>
<li>Speicherverwaltung nötig, Objekte müssen zerstört werden</li>
<li>Steile Lernkurve</li>
<li>Verstreute Dokumentation</li>
<li>Nicht plattformübergreifend deterministisch, nur auf demselben Gerät und mit sorgfältiger Konfiguration</li>
</ul>
<hr>
<h3>Jolt — moderne 3D-Physik auf AAA-Niveau</h3>
<p>Jolt Physics ist die Engine hinter Spielen wie Horizon Forbidden West, und JoltPhysics.js bringt sie als WASM-Port in den Browser. Sie ist ein starker Mittelweg zwischen Rapier und Ammo.js: mehr Funktionen als Rapier (Soft Bodies, Stoff, ein Fahrzeugcontroller mit Rädern) bei aktiver Pflege, die Ammo.js fehlt. Das npm-Paket heißt <code>jolt-physics</code>, und es gibt fertige Integrationen für React Three Fiber (<code>@react-three/jolt</code>) und Babylon.js.</p>
<pre><code class="language-bash">npm install jolt-physics
</code></pre>
<p>Wie Rapier ist es asynchron initialisiertes WASM mit einer geschwätzigen, aber vollständigen API, die die C++-Schnittstelle spiegelt. Greif zu Jolt, wenn du Funktionen der Bullet-Klasse willst (Soft Bodies, Fahrzeuge), aber eine gepflegte, moderne Codebasis statt Ammo.js bevorzugst.</p>
<hr>
<h3>Oimo.js — leichtgewichtig und schnell</h3>
<p>Oimo.js ist eine leichtgewichtige 3D-Physik-Engine. Gut für einfache Spiele, in denen du keine fortgeschrittenen Funktionen brauchst.</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">npm install oimo
</code></pre>
<p><strong>Grundaufbau:</strong></p>
<pre><code class="language-js">import * as OIMO from 'oimo'

const world = new OIMO.World({
  timestep: 1/60,
  iterations: 8,
  broadphase: 2, // 1: brute, 2: sweep &amp; prune, 3: volume tree
  worldscale: 1,
  random: true,
  gravity: [0, -9.8, 0]
})

// Create ground
world.add({
  type: 'box',
  size: [100, 1, 100],
  pos: [0, -0.5, 0],
  move: false
})

// Create falling sphere
const sphere = world.add({
  type: 'sphere',
  size: [1],
  pos: [0, 10, 0],
  move: true,
  density: 1,
  friction: 0.4,
  restitution: 0.2
})
</code></pre>
<p><strong>Spielschleife:</strong></p>
<pre><code class="language-js">function animate() {
  world.step()
  
  // Get position/rotation
  const pos = sphere.getPosition()
  const rot = sphere.getQuaternion()
  
  mesh.position.set(pos.x, pos.y, pos.z)
  mesh.quaternion.set(rot.x, rot.y, rot.z, rot.w)
  
  requestAnimationFrame(animate)
}
</code></pre>
<p><strong>Vorteile:</strong></p>
<ul>
<li>Sehr klein (~100 KB)</li>
<li>Einfache API</li>
<li>Gute Leistung bei einfachen Szenen</li>
<li>Eingebaute Babylon.js-Unterstützung</li>
</ul>
<p><strong>Nachteile:</strong></p>
<ul>
<li>Wenige Formen, nur Primitive</li>
<li>Keine Soft Bodies</li>
<li>Dünne Dokumentation</li>
<li>Weniger aktive Entwicklung</li>
<li>Wenige Gelenkoptionen</li>
</ul>
<hr>
<h2>2D-Physik-Engines</h2>
<h3>Matter.js — schön und eingängig</h3>
<p>Matter.js ist eine funktionsreiche 2D-Physik-Engine mit hervorragendem Rendering und guten Debug-Werkzeugen.</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">npm install matter-js
</code></pre>
<p><strong>Grundaufbau:</strong></p>
<pre><code class="language-js">import Matter from 'matter-js'

const { Engine, Render, World, Bodies, Runner } = Matter

// Create engine
const engine = Engine.create()

// Create renderer (optional, great for debugging)
const render = Render.create({
  element: document.body,
  engine: engine,
  options: {
    width: 800,
    height: 600,
    wireframes: false
  }
})

// Create bodies
const ground = Bodies.rectangle(400, 580, 810, 60, { 
  isStatic: true 
})

const box = Bodies.rectangle(400, 200, 80, 80, {
  restitution: 0.8,
  friction: 0.5
})

const circle = Bodies.circle(300, 100, 40, {
  restitution: 0.9
})

// Add to world
World.add(engine.world, [ground, box, circle])

// Run
Render.run(render)
Runner.run(Runner.create(), engine)
</code></pre>
<p><strong>Kollisions-Events:</strong></p>
<pre><code class="language-js">Matter.Events.on(engine, 'collisionStart', (event) =&gt; {
  event.pairs.forEach(pair =&gt; {
    console.log('Collision between:', pair.bodyA.label, pair.bodyB.label)
  })
})
</code></pre>
<p><strong>Constraints:</strong></p>
<pre><code class="language-js">// Pin constraint
const pin = Matter.Constraint.create({
  pointA: { x: 400, y: 100 },
  bodyB: box,
  stiffness: 0.9
})

// Spring between bodies
const spring = Matter.Constraint.create({
  bodyA: box,
  bodyB: circle,
  stiffness: 0.01,
  length: 100
})

World.add(engine.world, [pin, spring])
</code></pre>
<p><strong>Mausinteraktion:</strong></p>
<pre><code class="language-js">const mouse = Matter.Mouse.create(render.canvas)
const mouseConstraint = Matter.MouseConstraint.create(engine, {
  mouse: mouse,
  constraint: {
    stiffness: 0.2,
    render: { visible: false }
  }
})
World.add(engine.world, mouseConstraint)
</code></pre>
<p><strong>Vorteile:</strong></p>
<ul>
<li>Schönes Standard-Rendering</li>
<li>Gut zum Lernen und Prototypen</li>
<li>Eingängige API</li>
<li>Gute Dokumentation</li>
<li>Aktive Community</li>
<li>Standardmäßig deterministisch, Matter.Runner nutzt jetzt einen festen deterministischen Zeitschritt (der nicht feste Zeitschritt wurde in v0.20.0 entfernt)</li>
</ul>
<p><strong>Nachteile:</strong></p>
<ul>
<li>Kein CCD, schnelle Objekte können tunneln, mit Substepping abmildern</li>
<li>Leistungsprobleme bei vielen Körpern</li>
<li>Keine WASM-Option</li>
<li>Begrenzte Präzision bei komplexen Simulationen</li>
</ul>
<hr>
<h3>Planck.js — Box2D für JavaScript</h3>
<p>Planck.js ist eine vollständige Neufassung von Box2D in JavaScript. Praxiserprobte Physik, deterministisch bei festem Zeitschritt.</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">npm install planck
</code></pre>
<p><strong>Grundaufbau:</strong></p>
<pre><code class="language-js">import { World, Vec2, Box, Circle, Edge } from 'planck'

// Create world
const world = new World({
  gravity: Vec2(0, -10)
})

// Create ground
const ground = world.createBody()
ground.createFixture({
  shape: Edge(Vec2(-40, 0), Vec2(40, 0))
})

// Create dynamic box
const box = world.createBody({
  type: 'dynamic',
  position: Vec2(0, 10)
})
box.createFixture({
  shape: Box(1, 1),
  density: 1,
  friction: 0.3,
  restitution: 0.5
})
</code></pre>
<p><strong>Spielschleife mit festem Zeitschritt:</strong></p>
<pre><code class="language-js">const TIMESTEP = 1 / 60
const VELOCITY_ITERATIONS = 8
const POSITION_ITERATIONS = 3

function gameLoop() {
  world.step(TIMESTEP, VELOCITY_ITERATIONS, POSITION_ITERATIONS)
  
  // Iterate all bodies
  for (let body = world.getBodyList(); body; body = body.getNext()) {
    const pos = body.getPosition()
    const angle = body.getAngle()
    // Update your sprites...
  }
  
  requestAnimationFrame(gameLoop)
}
</code></pre>
<p><strong>Kollisions-Callbacks:</strong></p>
<pre><code class="language-js">world.on('begin-contact', (contact) =&gt; {
  const fixtureA = contact.getFixtureA()
  const fixtureB = contact.getFixtureB()
  console.log('Contact started')
})

world.on('end-contact', (contact) =&gt; {
  console.log('Contact ended')
})

world.on('pre-solve', (contact, oldManifold) =&gt; {
  // Can disable contact here
  // contact.setEnabled(false)
})
</code></pre>
<p><strong>Gelenke:</strong></p>
<pre><code class="language-js">import { RevoluteJoint, DistanceJoint, PrismaticJoint } from 'planck'

// Revolute joint (hinge)
const joint = world.createJoint(RevoluteJoint({
  bodyA: ground,
  bodyB: box,
  localAnchorA: Vec2(0, 5),
  localAnchorB: Vec2(-1, 0),
  enableMotor: true,
  maxMotorTorque: 1000,
  motorSpeed: 2
}))

// Distance joint (spring)
world.createJoint(DistanceJoint({
  bodyA: boxA,
  bodyB: boxB,
  localAnchorA: Vec2(0, 0),
  localAnchorB: Vec2(0, 0),
  length: 5,
  stiffness: 10,
  damping: 0.5
}))
</code></pre>
<p><strong>Vorteile:</strong></p>
<ul>
<li>Deterministisch mit festem Zeitschritt</li>
<li>Gut dokumentiert, die Box2D-Doku gilt</li>
<li>Gute Leistung</li>
<li>TypeScript-Unterstützung</li>
<li>Kleines Bundle</li>
</ul>
<p><strong>Nachteile:</strong></p>
<ul>
<li>CCD erfasst keine Gelenke, schnelle über Gelenke verbundene Objekte können sich dehnen</li>
<li>Steilere Lernkurve als Matter.js</li>
<li>Kein eingebauter Renderer</li>
<li>Box2D-Eigenheiten, der Maßstab der Einheiten zählt</li>
</ul>
<hr>
<h3>p2-es — flexible 2D-Physik</h3>
<p>p2-es ist der gepflegte Fork von p2.js. Gut für Spiele, die komplexe Constraints und Mechanismen brauchen.</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">npm install p2-es
</code></pre>
<p><strong>Grundaufbau:</strong></p>
<pre><code class="language-js">import * as p2 from 'p2-es'

const world = new p2.World({
  gravity: [0, -9.81]
})

// Ground
const groundBody = new p2.Body({
  mass: 0, // static
  position: [0, -1]
})
groundBody.addShape(new p2.Plane())
world.addBody(groundBody)

// Dynamic circle
const circleBody = new p2.Body({
  mass: 1,
  position: [0, 5]
})
circleBody.addShape(new p2.Circle({ radius: 0.5 }))
world.addBody(circleBody)
</code></pre>
<p><strong>Fortgeschrittene Constraints:</strong></p>
<pre><code class="language-js">// Gear constraint
const gear = new p2.GearConstraint(bodyA, bodyB, {
  ratio: 2 // bodyB rotates twice as fast
})
world.addConstraint(gear)

// Prismatic constraint (slider)
const prismatic = new p2.PrismaticConstraint(bodyA, bodyB, {
  localAnchorA: [0, 0],
  localAnchorB: [0, 0],
  localAxisA: [1, 0],
  disableRotationalLock: false
})
world.addConstraint(prismatic)

// Lock constraint (weld)
const lock = new p2.LockConstraint(bodyA, bodyB)
world.addConstraint(lock)
</code></pre>
<p><strong>Kontaktmaterialien:</strong></p>
<pre><code class="language-js">const ice = new p2.Material()
const rubber = new p2.Material()

const iceRubber = new p2.ContactMaterial(ice, rubber, {
  friction: 0.1,
  restitution: 0.9
})
world.addContactMaterial(iceRubber)

// Apply to shapes
iceBody.shapes[0].material = ice
rubberBody.shapes[0].material = rubber
</code></pre>
<p><strong>Vorteile:</strong></p>
<ul>
<li>Reichhaltiges Constraint-System</li>
<li>Kontaktmaterialien</li>
<li>Gut für Mechanismen und Maschinen</li>
<li>Schlafende Körper, gut für die Leistung</li>
<li>ES-Module, tree-shakable</li>
</ul>
<p><strong>Nachteile:</strong></p>
<ul>
<li>Kein eingebauter Renderer</li>
<li>Einige Formenpaare nicht unterstützt</li>
<li>Weniger aktiv als die Alternativen</li>
<li>Lücken in der Dokumentation</li>
</ul>
<hr>
<h2>Tipps zur Leistung</h2>
<h3>1. Nutz einen festen Zeitschritt</h3>
<pre><code class="language-js">const TIMESTEP = 1 / 60
let accumulator = 0

function gameLoop(deltaTime) {
  accumulator += deltaTime
  
  while (accumulator &gt;= TIMESTEP) {
    world.step(TIMESTEP)
    accumulator -= TIMESTEP
  }
  
  // Interpolate for smooth rendering
  const alpha = accumulator / TIMESTEP
  // lerp(previousState, currentState, alpha)
}
</code></pre>
<h3>2. Lass inaktive Körper schlafen</h3>
<p>Die meisten Engines unterstützen Schlafen. Schalte es ein:</p>
<pre><code class="language-js">// Cannon-es
world.allowSleep = true
body.allowSleep = true
body.sleepSpeedLimit = 0.1
body.sleepTimeLimit = 1

// Rapier
bodyDesc.setCanSleep(true)
</code></pre>
<h3>3. Nutz einfache Kollisionsformen</h3>
<pre><code class="language-js">// GOOD: Simple primitives
const sphere = new CANNON.Sphere(1)
const box = new CANNON.Box(new CANNON.Vec3(1, 1, 1))

// AVOID: Complex trimesh for dynamic bodies
const trimesh = new CANNON.Trimesh(vertices, indices) // Slow!
</code></pre>
<h3>4. Reduziere Solver-Iterationen, vorsichtig</h3>
<pre><code class="language-js">// Cannon-es
world.solver.iterations = 5 // Default 10

// Rapier - configured at world creation
</code></pre>
<h3>5. Lass Physik in einem Web Worker laufen</h3>
<pre><code class="language-js">// main.js
const worker = new Worker('physics-worker.js')

worker.postMessage({ type: 'step', deltaTime: 1/60 })
worker.onmessage = (e) =&gt; {
  const { positions, rotations } = e.data
  // Update render objects
}

// physics-worker.js
importScripts('cannon-es.js')
const world = new CANNON.World()

onmessage = (e) =&gt; {
  if (e.data.type === 'step') {
    world.step(e.data.deltaTime)
    postMessage({
      positions: bodies.map(b =&gt; b.position.toArray()),
      rotations: bodies.map(b =&gt; b.quaternion.toArray())
    })
  }
}
</code></pre>
<hr>
<h2>Wann was passt</h2>
<table>
<thead>
<tr>
<th>Szenario</th>
<th>Empfehlung</th>
<th>Warum</th>
</tr>
</thead>
<tbody>
<tr>
<td>3D-Spiel in Produktion</td>
<td><strong>Rapier</strong></td>
<td>Beste Leistung, moderne API, Fahrzeuge</td>
</tr>
<tr>
<td>3D-Prototyp</td>
<td><strong>Cannon-es</strong></td>
<td>Einfach, gut zu debuggen</td>
</tr>
<tr>
<td>Soft Bodies, Stoff, Seile</td>
<td><strong>Ammo.js</strong> oder <strong>Jolt</strong></td>
<td>Beide unterstützen Stoff und Soft Bodies, Jolts WASM-Port (JoltPhysics.js) wird aktiver gepflegt</td>
</tr>
<tr>
<td>Einfaches 3D-Browser-Spiel</td>
<td><strong>Oimo.js</strong></td>
<td>Winzig, ausreichend</td>
</tr>
<tr>
<td>2D-Game-Jam</td>
<td><strong>Matter.js</strong></td>
<td>Schnell eingerichtet, Rendering eingebaut</td>
</tr>
<tr>
<td>Präziser Plattformer</td>
<td><strong>Planck.js</strong></td>
<td>Determinismus mit festem Zeitschritt, gut dokumentiert</td>
</tr>
<tr>
<td>Komplexe 2D-Mechanismen</td>
<td><strong>p2-es</strong></td>
<td>Reichhaltiges Constraint-System</td>
</tr>
<tr>
<td>Leistungskritisches 2D</td>
<td><strong>Box2D WASM</strong></td>
<td>Schnellste 2D-Option, echtes CCD</td>
</tr>
</tbody>
</table>
<hr>
<h2>Integration mit Renderern</h2>
<h3>Three.js + Rapier</h3>
<pre><code class="language-js">import * as THREE from 'three'
import RAPIER from '@dimforge/rapier3d'

await RAPIER.init()

const scene = new THREE.Scene()
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 })

const bodies = new Map() // physics body -&gt; mesh

function createPhysicsBox(x, y, z) {
  // Physics
  const bodyDesc = RAPIER.RigidBodyDesc.dynamic().setTranslation(x, y, z)
  const body = world.createRigidBody(bodyDesc)
  const collider = RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5)
  world.createCollider(collider, body)
  
  // Render
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0x00ff00 })
  )
  scene.add(mesh)
  
  bodies.set(body, mesh)
  return body
}

function syncPhysics() {
  bodies.forEach((mesh, body) =&gt; {
    const pos = body.translation()
    const rot = body.rotation()
    mesh.position.set(pos.x, pos.y, pos.z)
    mesh.quaternion.set(rot.x, rot.y, rot.z, rot.w)
  })
}
</code></pre>
<h3>PixiJS + Matter.js</h3>
<pre><code class="language-js">import * as PIXI from 'pixi.js'
import Matter from 'matter-js'

const app = new PIXI.Application({ width: 800, height: 600 })
document.body.appendChild(app.view)

const engine = Matter.Engine.create()
const bodies = new Map()

function createPhysicsSprite(texture, x, y, width, height) {
  // Physics
  const body = Matter.Bodies.rectangle(x, y, width, height)
  Matter.World.add(engine.world, body)
  
  // Render
  const sprite = new PIXI.Sprite(texture)
  sprite.anchor.set(0.5)
  app.stage.addChild(sprite)
  
  bodies.set(body, sprite)
  return body
}

app.ticker.add(() =&gt; {
  Matter.Engine.update(engine, 1000 / 60)
  
  bodies.forEach((sprite, body) =&gt; {
    sprite.position.set(body.position.x, body.position.y)
    sprite.rotation = body.angle
  })
})
</code></pre>
<hr>
<h2>Typische Stolperfallen</h2>
<h3>Der Maßstab zählt</h3>
<p>Physik-Engines arbeiten am besten mit realem Maßstab (1 Einheit = 1 Meter). Nutz nicht direkt Pixelkoordinaten.</p>
<pre><code class="language-js">// BAD: Using pixel positions
const body = Bodies.circle(400, 300, 50) // 50 pixel radius?

// GOOD: Use a scale factor
const SCALE = 50 // 50 pixels per meter
const body = Bodies.circle(8, 6, 1) // 1 meter radius
// Then multiply by SCALE when rendering
</code></pre>
<h3>Speicherlecks bei WASM-Engines</h3>
<p>Räum Körper beim Entfernen immer sauber auf:</p>
<pre><code class="language-js">// Rapier
world.removeRigidBody(body)

// Ammo.js
physicsWorld.removeRigidBody(body)
Ammo.destroy(body)
Ammo.destroy(shape)
Ammo.destroy(motionState)
</code></pre>
<h3>Tunneln, wenn Objekte einander durchdringen</h3>
<p>Schnelle Objekte können durch dünne Wände tunneln. Lösungen:</p>
<pre><code class="language-js">// Rapier: Enable CCD
const bodyDesc = RAPIER.RigidBodyDesc.dynamic().setCcdEnabled(true)

// Cannon-es: Use smaller timesteps or thicker walls
world.step(1/120) // 120 Hz instead of 60
</code></pre>
<hr>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/webgl-fundamentals-for-games">WebGL-Grundlagen für Spieleentwickler</a></li>
<li><a href="/de/tutorials/web-workers-game-logic">Web Workers für Spiellogik</a> — Physik in einen Worker-Thread auslagern</li>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a> — das Muster mit festem Zeitschritt, das jede Physik-Engine nutzt</li>
<li><a href="/de/guides/web-games-stack-2026">Tech-Stack für Web-Spiele 2026</a> — wo Physik-Engines in den größeren Stack passen</li>
<li><a href="/de/guides/web-game-engines-comparison">Web-Game-Engines im Vergleich</a> — Engines mit eingebauter Physik</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://rapier.rs/docs/">Rapier documentation</a> — offizielle Doku und Beispiele zu Rapier</li>
<li><a href="https://pmndrs.github.io/cannon-es/docs/">Cannon-es documentation</a> — API-Referenz und Anleitungen</li>
<li><a href="https://brm.io/matter-js/docs/">Matter.js documentation</a> — vollständige API-Referenz</li>
<li><a href="https://piqnt.com/planck.js/">Planck.js documentation</a> — Box2D für JavaScript</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Gamepad-API für Web-Spiele]]></title>
            <link>https://app.cinevva.com/de/tutorials/gamepad-api</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/gamepad-api</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Vollständige Gamepad-Unterstützung: Erkennung, Tastenbelegung, Analogsticks, Vibration und mehrere Controller. Eingabe in Konsolenqualität im Web.]]></description>
            <content:encoded><![CDATA[<h1>Gamepad-API für Web-Spiele</h1>
<p>Die Gamepad-API bringt Controller-Unterstützung in Konsolenqualität in Web-Spiele. Unterstütze Xbox, PlayStation, Switch Pro und generische Controller.</p>
<h2>1) Gamepads erkennen</h2>
<pre><code class="language-js">window.addEventListener('gamepadconnected', (e) =&gt; {
  console.log('Gamepad connected:', e.gamepad.id)
  console.log('Index:', e.gamepad.index)
  console.log('Buttons:', e.gamepad.buttons.length)
  console.log('Axes:', e.gamepad.axes.length)
})

window.addEventListener('gamepaddisconnected', (e) =&gt; {
  console.log('Gamepad disconnected:', e.gamepad.id)
})
</code></pre>
<h2>2) Den Gamepad-Zustand abfragen</h2>
<p>Der Gamepad-Zustand muss abgefragt werden, er kommt nicht als Event:</p>
<pre><code class="language-js">function pollGamepads() {
  const gamepads = navigator.getGamepads()
  
  for (const gamepad of gamepads) {
    if (gamepad) {
      processGamepad(gamepad)
    }
  }
}

// Call in your game loop
function gameLoop() {
  pollGamepads()
  update()
  render()
  requestAnimationFrame(gameLoop)
}
</code></pre>
<h2>3) Die Standard-Tastenbelegung</h2>
<p>Die &quot;Standard&quot;-Belegung (die meisten Xbox- und PlayStation-Controller):</p>
<pre><code class="language-js">const BUTTONS = {
  A: 0,           // Cross (PS) / A (Xbox)
  B: 1,           // Circle (PS) / B (Xbox)
  X: 2,           // Square (PS) / X (Xbox)
  Y: 3,           // Triangle (PS) / Y (Xbox)
  LB: 4,          // L1 (PS) / LB (Xbox)
  RB: 5,          // R1 (PS) / RB (Xbox)
  LT: 6,          // L2 (PS) / LT (Xbox)
  RT: 7,          // R2 (PS) / RT (Xbox)
  SELECT: 8,      // Share (PS) / Back (Xbox)
  START: 9,       // Options (PS) / Start (Xbox)
  L3: 10,         // Left stick press
  R3: 11,         // Right stick press
  DPAD_UP: 12,
  DPAD_DOWN: 13,
  DPAD_LEFT: 14,
  DPAD_RIGHT: 15,
  HOME: 16,       // PS button / Xbox button
}

const AXES = {
  LEFT_X: 0,
  LEFT_Y: 1,
  RIGHT_X: 2,
  RIGHT_Y: 3,
}
</code></pre>
<h2>4) Tasten und Sticks auslesen</h2>
<pre><code class="language-js">function processGamepad(gamepad) {
  // Buttons (digital or analog)
  const jump = gamepad.buttons[BUTTONS.A].pressed
  const shoot = gamepad.buttons[BUTTONS.X].pressed
  
  // Triggers (analog 0-1)
  const leftTrigger = gamepad.buttons[BUTTONS.LT].value
  const rightTrigger = gamepad.buttons[BUTTONS.RT].value
  
  // Sticks (analog -1 to 1)
  const leftX = gamepad.axes[AXES.LEFT_X]
  const leftY = gamepad.axes[AXES.LEFT_Y]
  const rightX = gamepad.axes[AXES.RIGHT_X]
  const rightY = gamepad.axes[AXES.RIGHT_Y]
  
  return { jump, shoot, leftTrigger, rightTrigger, leftX, leftY, rightX, rightY }
}
</code></pre>
<h2>5) Totzonen behandeln</h2>
<p>Sticks ruhen selten exakt auf 0:</p>
<pre><code class="language-js">function applyDeadzone(value, deadzone = 0.15) {
  if (Math.abs(value) &lt; deadzone) return 0
  
  // Rescale remaining range to 0-1
  const sign = Math.sign(value)
  const adjusted = (Math.abs(value) - deadzone) / (1 - deadzone)
  return sign * adjusted
}

function getStickInput(gamepad) {
  return {
    leftX: applyDeadzone(gamepad.axes[AXES.LEFT_X]),
    leftY: applyDeadzone(gamepad.axes[AXES.LEFT_Y]),
    rightX: applyDeadzone(gamepad.axes[AXES.RIGHT_X]),
    rightY: applyDeadzone(gamepad.axes[AXES.RIGHT_Y]),
  }
}
</code></pre>
<h2>6) Radiale Totzone (besser für 2D-Bewegung)</h2>
<pre><code class="language-js">function applyRadialDeadzone(x, y, deadzone = 0.15) {
  const magnitude = Math.sqrt(x * x + y * y)
  
  if (magnitude &lt; deadzone) {
    return { x: 0, y: 0 }
  }
  
  const normalized = {
    x: x / magnitude,
    y: y / magnitude,
  }
  
  const adjusted = (magnitude - deadzone) / (1 - deadzone)
  
  return {
    x: normalized.x * adjusted,
    y: normalized.y * adjusted,
  }
}

// Usage
const stick = applyRadialDeadzone(
  gamepad.axes[AXES.LEFT_X],
  gamepad.axes[AXES.LEFT_Y]
)
player.vx = stick.x * player.speed
player.vy = stick.y * player.speed
</code></pre>
<h2>7) Ein vollständiger Gamepad-Manager</h2>
<pre><code class="language-js">class GamepadManager {
  constructor() {
    this.gamepads = new Map()
    this.prevState = new Map()
    
    window.addEventListener('gamepadconnected', (e) =&gt; {
      this.gamepads.set(e.gamepad.index, e.gamepad)
      console.log('Connected:', e.gamepad.id)
    })
    
    window.addEventListener('gamepaddisconnected', (e) =&gt; {
      this.gamepads.delete(e.gamepad.index)
      this.prevState.delete(e.gamepad.index)
    })
  }
  
  poll() {
    const gamepads = navigator.getGamepads()
    for (const gp of gamepads) {
      if (gp) this.gamepads.set(gp.index, gp)
    }
  }
  
  getState(index = 0) {
    const gp = this.gamepads.get(index)
    if (!gp) return null
    
    const stick = applyRadialDeadzone(gp.axes[0], gp.axes[1])
    const rightStick = applyRadialDeadzone(gp.axes[2], gp.axes[3])
    
    return {
      connected: true,
      leftStick: stick,
      rightStick: rightStick,
      
      // Face buttons
      a: gp.buttons[0].pressed,
      b: gp.buttons[1].pressed,
      x: gp.buttons[2].pressed,
      y: gp.buttons[3].pressed,
      
      // Bumpers
      lb: gp.buttons[4].pressed,
      rb: gp.buttons[5].pressed,
      
      // Triggers (analog)
      lt: gp.buttons[6].value,
      rt: gp.buttons[7].value,
      
      // D-pad
      dpadUp: gp.buttons[12].pressed,
      dpadDown: gp.buttons[13].pressed,
      dpadLeft: gp.buttons[14].pressed,
      dpadRight: gp.buttons[15].pressed,
      
      // Meta
      select: gp.buttons[8].pressed,
      start: gp.buttons[9].pressed,
    }
  }
  
  isPressed(index, button) {
    const gp = this.gamepads.get(index)
    return gp?.buttons[button]?.pressed || false
  }
  
  wasJustPressed(index, button) {
    const current = this.isPressed(index, button)
    const prev = this.prevState.get(index)?.[button] || false
    return current &amp;&amp; !prev
  }
  
  endFrame() {
    for (const [index, gp] of this.gamepads) {
      const state = {}
      for (let i = 0; i &lt; gp.buttons.length; i++) {
        state[i] = gp.buttons[i].pressed
      }
      this.prevState.set(index, state)
    }
  }
}

// Usage
const gamepadManager = new GamepadManager()

function update() {
  gamepadManager.poll()
  
  const state = gamepadManager.getState(0)
  if (state) {
    player.vx = state.leftStick.x * player.speed
    player.vy = state.leftStick.y * player.speed
    
    if (gamepadManager.wasJustPressed(0, BUTTONS.A)) {
      player.jump()
    }
  }
  
  gamepadManager.endFrame()
}
</code></pre>
<h2>8) Vibration und haptisches Feedback</h2>
<pre><code class="language-js">function vibrate(gamepad, duration = 200, weakMagnitude = 0.5, strongMagnitude = 0.5) {
  if (gamepad.vibrationActuator) {
    gamepad.vibrationActuator.playEffect('dual-rumble', {
      startDelay: 0,
      duration,
      weakMagnitude,   // High frequency motor
      strongMagnitude, // Low frequency motor
    })
  }
}

// Usage
function onPlayerHit() {
  const gp = navigator.getGamepads()[0]
  if (gp) vibrate(gp, 150, 0.3, 0.6)
}

function onExplosion() {
  const gp = navigator.getGamepads()[0]
  if (gp) vibrate(gp, 300, 0.8, 1.0)
}
</code></pre>
<h2>9) Mehrere Spieler</h2>
<pre><code class="language-js">class LocalMultiplayer {
  constructor(maxPlayers = 4) {
    this.maxPlayers = maxPlayers
    this.players = []
  }
  
  getActivePlayers() {
    const gamepads = navigator.getGamepads()
    const active = []
    
    for (let i = 0; i &lt; this.maxPlayers; i++) {
      if (gamepads[i]) {
        active.push({
          index: i,
          gamepad: gamepads[i],
          state: this.getState(gamepads[i])
        })
      }
    }
    
    return active
  }
  
  getState(gp) {
    // ... same as before
  }
}

// In game loop
const multiplayer = new LocalMultiplayer(4)

function update() {
  const players = multiplayer.getActivePlayers()
  
  for (const { index, state } of players) {
    updatePlayer(index, state)
  }
}
</code></pre>
<h2>10) Rückfall und kombinierte Eingabe</h2>
<p>Unterstütze Tastatur und Gamepad gleichzeitig:</p>
<pre><code class="language-js">class InputManager {
  constructor() {
    this.keyboard = new KeyboardInput()
    this.gamepad = new GamepadManager()
  }
  
  poll() {
    this.gamepad.poll()
  }
  
  getInput(playerIndex = 0) {
    const gpState = this.gamepad.getState(playerIndex)
    const kbState = this.keyboard.getState()
    
    // Combine inputs (gamepad takes priority for analog)
    return {
      moveX: gpState?.leftStick.x || (kbState.left ? -1 : kbState.right ? 1 : 0),
      moveY: gpState?.leftStick.y || (kbState.up ? -1 : kbState.down ? 1 : 0),
      jump: gpState?.a || kbState.space,
      shoot: gpState?.x || kbState.z,
      pause: gpState?.start || kbState.escape,
    }
  }
  
  endFrame() {
    this.gamepad.endFrame()
    this.keyboard.endFrame()
  }
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/game-input-handling">Eingaben in Spielen</a></li>
<li><a href="/de/tutorials/mobile-friendly-web-games">Mobilfreundliche Web-Spiele</a></li>
<li><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a></li>
<li><a href="/de/tutorials/pointer-lock-fps">Pointer Lock für Shooter</a> — Gamepad mit Mausblick kombinieren</li>
<li><a href="/de/guides/co-op-game-design">Koop-Spieldesign</a> — für mehrere Controller-Eingaben im lokalen Koop entwerfen</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN: Gamepad API</a> — vollständige API-Referenz</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API">MDN: Using the Gamepad API</a> — Schritt-für-Schritt-Anleitung</li>
<li><a href="https://gamepad-tester.com/">Gamepad Tester</a> — teste deine Controller-Belegung im Browser</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Best Practices beim Einbetten von Spielen per Iframe]]></title>
            <link>https://app.cinevva.com/de/tutorials/iframe-embedding-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/iframe-embedding-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Web-Spiele richtig in Iframes einbetten: Sicherheit, Kommunikation, Vollbild und Plattformanforderungen. So wird dein Spiel überall einbettbar.]]></description>
            <content:encoded><![CDATA[<h1>Best Practices beim Einbetten von Spielen per Iframe</h1>
<p>Viele Plattformen (<a href="http://Itch.io">Itch.io</a>, Newgrounds, Cinevva, deine eigene Seite) betten Spiele in Iframes ein. Dieses Tutorial zeigt, wie dein Spiel eingebettet gut funktioniert.</p>
<h2>1) Grundaufbau für Einbettung</h2>
<p>Dein Spiel sollte laufen, ohne die volle Kontrolle über die Seite zu brauchen:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;meta charset=&quot;UTF-8&quot;&gt;
  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
  &lt;style&gt;
    * { margin: 0; padding: 0; }
    html, body { width: 100%; height: 100%; overflow: hidden; }
    canvas { display: block; width: 100%; height: 100%; }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;canvas id=&quot;game&quot;&gt;&lt;/canvas&gt;
  &lt;script src=&quot;game.js&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h2>2) Den Iframe-Kontext erkennen</h2>
<pre><code class="language-js">const isEmbedded = window.self !== window.top

if (isEmbedded) {
  // Adjust behavior for embedded context
  hideExternalLinks()
  adjustUIForSmallSize()
}

function hideExternalLinks() {
  document.querySelectorAll('a[target=&quot;_blank&quot;]').forEach(link =&gt; {
    link.style.display = 'none'
  })
}
</code></pre>
<h2>3) Nötige Iframe-Berechtigungen erlauben</h2>
<p>Die übergeordnete Seite bestimmt, was dein Iframe darf. Übliche Berechtigungen:</p>
<pre><code class="language-html">&lt;iframe 
  src=&quot;https://game.example.com&quot;
  allow=&quot;fullscreen; autoplay; gamepad; pointer-lock&quot;
  sandbox=&quot;allow-scripts allow-same-origin allow-pointer-lock allow-popups&quot;
&gt;&lt;/iframe&gt;
</code></pre>
<p>Dein Spiel sollte mit fehlenden Berechtigungen sauber umgehen:</p>
<pre><code class="language-js">// Check if fullscreen is available
const canFullscreen = document.fullscreenEnabled || document.webkitFullscreenEnabled

if (!canFullscreen) {
  document.getElementById('fullscreen-btn').style.display = 'none'
}
</code></pre>
<h2>4) Vollbild aus dem Iframe</h2>
<p>Vollbild verlangt das Attribut <code>allow=&quot;fullscreen&quot;</code>:</p>
<pre><code class="language-js">async function requestFullscreen() {
  const elem = document.documentElement
  
  try {
    if (elem.requestFullscreen) {
      await elem.requestFullscreen()
    } else if (elem.webkitRequestFullscreen) {
      await elem.webkitRequestFullscreen()
    }
  } catch (err) {
    // Fullscreen not allowed - show message
    showMessage('Fullscreen not available when embedded')
  }
}
</code></pre>
<h2>5) Mit der übergeordneten Seite kommunizieren</h2>
<p>Nutze <code>postMessage</code> für sichere Kommunikation über Origins hinweg:</p>
<p><strong>In deinem Spiel:</strong></p>
<pre><code class="language-js">// Send message to parent
function notifyParent(type, data) {
  if (window.parent !== window) {
    window.parent.postMessage({ type, data, source: 'game' }, '*')
  }
}

// Receive messages from parent
window.addEventListener('message', (event) =&gt; {
  // Validate origin if needed
  // if (event.origin !== 'https://trusted-host.com') return
  
  const { type, data } = event.data
  
  switch (type) {
    case 'pause':
      pauseGame()
      break
    case 'resume':
      resumeGame()
      break
    case 'setVolume':
      setVolume(data.volume)
      break
  }
})

// Notify when game is ready
window.addEventListener('load', () =&gt; {
  notifyParent('ready', { width: 800, height: 600 })
})

// Notify on game events
function onGameOver(score) {
  notifyParent('gameover', { score })
}
</code></pre>
<p><strong>In der übergeordneten Seite:</strong></p>
<pre><code class="language-js">const iframe = document.getElementById('game-iframe')

iframe.addEventListener('load', () =&gt; {
  // Listen for messages from game
  window.addEventListener('message', (event) =&gt; {
    if (event.source !== iframe.contentWindow) return
    if (event.data.source !== 'game') return
    
    const { type, data } = event.data
    
    if (type === 'ready') {
      console.log('Game ready:', data)
    }
    if (type === 'gameover') {
      showScoreModal(data.score)
    }
  })
})

// Send commands to game
function pauseGame() {
  iframe.contentWindow.postMessage({ type: 'pause' }, '*')
}
</code></pre>
<h2>6) Mit dem Fokus umgehen</h2>
<p>Iframes können den Fokus verlieren, was die Tastatureingabe kaputt macht:</p>
<pre><code class="language-js">// Auto-focus canvas when clicked
canvas.addEventListener('click', () =&gt; {
  canvas.focus()
})

// Make canvas focusable
canvas.tabIndex = 1

// Handle focus loss
window.addEventListener('blur', () =&gt; {
  // Reset held keys
  input.left = input.right = input.up = input.down = false
  
  if (isEmbedded) {
    // Optionally pause
    // pauseGame()
  }
})

// Request focus from parent
function requestFocus() {
  notifyParent('requestFocus', {})
}
</code></pre>
<h2>7) Auf verschiedene Einbettungsgrößen reagieren</h2>
<p>Rechne mit unterschiedlichen Maßen:</p>
<pre><code class="language-js">function handleResize() {
  const width = window.innerWidth
  const height = window.innerHeight
  
  // Adjust UI based on size
  if (width &lt; 400 || height &lt; 300) {
    enableCompactUI()
  } else {
    enableFullUI()
  }
  
  // Scale game appropriately
  resizeCanvas(width, height)
}

window.addEventListener('resize', handleResize)
handleResize()
</code></pre>
<h2>8) Ladeanzeige für Embeds</h2>
<p>Zeig sofort irgendetwas:</p>
<pre><code class="language-js">// Inline in HTML for instant display
const loadingHTML = `
  &lt;div id=&quot;loading&quot; style=&quot;
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #1a1a2e;
    color: #fff;
    font-family: sans-serif;
  &quot;&gt;
    &lt;div&gt;
      &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
      &lt;p&gt;Loading...&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
`

// Remove when game is ready
function hideLoading() {
  const loading = document.getElementById('loading')
  if (loading) {
    loading.style.opacity = '0'
    loading.style.transition = 'opacity 0.3s'
    setTimeout(() =&gt; loading.remove(), 300)
  }
}
</code></pre>
<h2>9) Plattformeigene SDKs</h2>
<p>Manche Plattformen haben eigene APIs:</p>
<p><strong><a href="http://Itch.io">Itch.io</a>:</strong></p>
<pre><code class="language-js">// No SDK required, but can use postMessage for achievements
window.parent.postMessage({ type: 'itch-achievement', data: { id: 'first-win' }}, '*')
</code></pre>
<p><strong>Newgrounds:</strong></p>
<pre><code class="language-js">// Include Newgrounds.io SDK
const ngio = new Newgrounds.io.core('APP_ID', 'AES_KEY')
ngio.callComponent('Medal.unlock', { id: 12345 })
</code></pre>
<p><strong>Cinevva:</strong></p>
<pre><code class="language-js">// Signal game is ready
window.parent.postMessage({ type: 'cinevva:ready' }, '*')

// Signal playable state
window.parent.postMessage({ type: 'cinevva:playable' }, '*')
</code></pre>
<h2>10) Sicherheitsaspekte</h2>
<pre><code class="language-js">// Validate message origins when sensitive
window.addEventListener('message', (event) =&gt; {
  const trustedOrigins = [
    'https://itch.io',
    'https://cinevva.com',
    'https://yoursite.com'
  ]
  
  if (!trustedOrigins.includes(event.origin)) {
    return // Ignore untrusted messages
  }
  
  // Process message...
})

// Don't expose sensitive operations via postMessage
// Only allow whitelisted commands
const allowedCommands = ['pause', 'resume', 'setVolume', 'mute']

window.addEventListener('message', (event) =&gt; {
  const { type } = event.data
  if (!allowedCommands.includes(type)) return
  
  // Handle command...
})
</code></pre>
<h2>Testcheckliste</h2>
<ul>
<li><strong>Lokal testen</strong>: Nutz einen einfachen HTTP-Server, nicht <code>file://</code></li>
<li><strong>Cross-Origin</strong>: Teste mit echter Iframe-Einbettung</li>
<li><strong>Berechtigungen</strong>: Teste mit eingeschränkter Sandbox</li>
<li><strong>Fokus</strong>: Teste die Tastatur, nachdem du außerhalb des Iframes geklickt hast</li>
<li><strong>Größenänderung</strong>: Teste verschiedene Einbettungsgrößen</li>
<li><strong>Mobil</strong>: Teste Touch im eingebetteten Kontext</li>
</ul>
<pre><code class="language-html">&lt;!-- Test embed page --&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body style=&quot;background: #333; padding: 20px;&quot;&gt;
  &lt;h1 style=&quot;color: #fff;&quot;&gt;Embed Test&lt;/h1&gt;
  &lt;iframe 
    src=&quot;http://localhost:8000&quot; 
    width=&quot;800&quot; 
    height=&quot;600&quot;
    allow=&quot;fullscreen; autoplay; gamepad&quot;
  &gt;&lt;/iframe&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/mobile-friendly-web-games">Mobilfreundliche Web-Spiele</a></li>
<li><a href="/de/creators">Für Creator</a></li>
<li><a href="/de/tutorials/coop-coep-sharedarraybuffer">COOP/COEP und SharedArrayBuffer</a> — Cross-Origin-Header, die die Iframe-Einbettung beeinflussen</li>
<li><a href="/de/guides/itch-io-launch-guide">Wie du dein Spiel auf itch.io veröffentlichst</a> — <a href="http://itch.io">itch.io</a> bettet Browser-Spiele nativ in Iframes ein</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe">MDN: iframe element</a> — vollständige Referenz zu den Iframe-Attributen</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy">MDN: Permissions Policy</a> — steuern, welche Funktionen Iframes nutzen dürfen</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage">MDN: postMessage API</a> — Kommunikation zwischen Iframe und übergeordneter Seite</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Spielstand mit IndexedDB speichern und laden]]></title>
            <link>https://app.cinevva.com/de/tutorials/indexeddb-game-saves</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/indexeddb-game-saves</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Dauerhafte Spielstände mit IndexedDB: Fortschritt, Einstellungen und große Daten ablegen. Asynchrone Muster, die dein Spiel nicht blockieren.]]></description>
            <content:encoded><![CDATA[<h1>Spielstand mit IndexedDB speichern und laden</h1>
<p>IndexedDB ist die beste Option für dauerhafte Spieldaten im Browser. Es kommt mit großen Datenmengen zurecht, funktioniert offline und blockiert den Haupt-Thread nicht.</p>
<h2>1) Warum IndexedDB statt localStorage?</h2>
<table>
<thead>
<tr>
<th>Merkmal</th>
<th>localStorage</th>
<th>IndexedDB</th>
</tr>
</thead>
<tbody>
<tr>
<td>Speichergrenze</td>
<td>~5-10 MB</td>
<td>50+ MB (oft GB)</td>
</tr>
<tr>
<td>Datentypen</td>
<td>nur Strings</td>
<td>Objekte, Blobs, Arrays</td>
</tr>
<tr>
<td>Asynchron</td>
<td>nein (blockiert)</td>
<td>ja</td>
</tr>
<tr>
<td>Indizierte Abfragen</td>
<td>nein</td>
<td>ja</td>
</tr>
</tbody>
</table>
<p>Für Spiele ist IndexedDB fast immer die richtige Wahl.</p>
<h2>2) Eine Datenbank öffnen</h2>
<pre><code class="language-js">function openGameDB() {
  return new Promise((resolve, reject) =&gt; {
    const request = indexedDB.open('MyGame', 1)
    
    request.onerror = () =&gt; reject(request.error)
    request.onsuccess = () =&gt; resolve(request.result)
    
    request.onupgradeneeded = (event) =&gt; {
      const db = event.target.result
      
      // Create stores
      if (!db.objectStoreNames.contains('saves')) {
        db.createObjectStore('saves', { keyPath: 'slot' })
      }
      if (!db.objectStoreNames.contains('settings')) {
        db.createObjectStore('settings', { keyPath: 'key' })
      }
    }
  })
}
</code></pre>
<h2>3) Spielstand speichern</h2>
<pre><code class="language-js">async function saveGame(slot, gameState) {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) =&gt; {
    const tx = db.transaction('saves', 'readwrite')
    const store = tx.objectStore('saves')
    
    const saveData = {
      slot,
      state: gameState,
      timestamp: Date.now(),
    }
    
    const request = store.put(saveData)
    request.onsuccess = () =&gt; resolve()
    request.onerror = () =&gt; reject(request.error)
  })
}
</code></pre>
<h2>4) Spielstand laden</h2>
<pre><code class="language-js">async function loadGame(slot) {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) =&gt; {
    const tx = db.transaction('saves', 'readonly')
    const store = tx.objectStore('saves')
    
    const request = store.get(slot)
    request.onsuccess = () =&gt; resolve(request.result?.state || null)
    request.onerror = () =&gt; reject(request.error)
  })
}
</code></pre>
<h2>5) Alle Spielstände auflisten</h2>
<pre><code class="language-js">async function listSaves() {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) =&gt; {
    const tx = db.transaction('saves', 'readonly')
    const store = tx.objectStore('saves')
    
    const request = store.getAll()
    request.onsuccess = () =&gt; resolve(request.result)
    request.onerror = () =&gt; reject(request.error)
  })
}
</code></pre>
<h2>6) Einen Spielstand löschen</h2>
<pre><code class="language-js">async function deleteSave(slot) {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) =&gt; {
    const tx = db.transaction('saves', 'readwrite')
    const store = tx.objectStore('saves')
    
    const request = store.delete(slot)
    request.onsuccess = () =&gt; resolve()
    request.onerror = () =&gt; reject(request.error)
  })
}
</code></pre>
<h2>7) Eine vollständige GameStorage-Klasse</h2>
<pre><code class="language-js">class GameStorage {
  constructor(dbName = 'GameData', version = 1) {
    this.dbName = dbName
    this.version = version
    this.db = null
  }
  
  async init() {
    this.db = await this.openDB()
  }
  
  openDB() {
    return new Promise((resolve, reject) =&gt; {
      const request = indexedDB.open(this.dbName, this.version)
      request.onerror = () =&gt; reject(request.error)
      request.onsuccess = () =&gt; resolve(request.result)
      request.onupgradeneeded = (e) =&gt; {
        const db = e.target.result
        if (!db.objectStoreNames.contains('saves')) {
          db.createObjectStore('saves', { keyPath: 'slot' })
        }
        if (!db.objectStoreNames.contains('settings')) {
          db.createObjectStore('settings', { keyPath: 'key' })
        }
        if (!db.objectStoreNames.contains('assets')) {
          db.createObjectStore('assets', { keyPath: 'url' })
        }
      }
    })
  }
  
  async save(slot, data) {
    const tx = this.db.transaction('saves', 'readwrite')
    tx.objectStore('saves').put({ slot, data, timestamp: Date.now() })
    return tx.complete
  }
  
  async load(slot) {
    const tx = this.db.transaction('saves', 'readonly')
    const result = await this.promisify(tx.objectStore('saves').get(slot))
    return result?.data || null
  }
  
  async setSetting(key, value) {
    const tx = this.db.transaction('settings', 'readwrite')
    tx.objectStore('settings').put({ key, value })
  }
  
  async getSetting(key, defaultValue = null) {
    const tx = this.db.transaction('settings', 'readonly')
    const result = await this.promisify(tx.objectStore('settings').get(key))
    return result?.value ?? defaultValue
  }
  
  promisify(request) {
    return new Promise((resolve, reject) =&gt; {
      request.onsuccess = () =&gt; resolve(request.result)
      request.onerror = () =&gt; reject(request.error)
    })
  }
}
</code></pre>
<h2>8) Binärdaten ablegen (Texturen, Audio)</h2>
<p>IndexedDB kommt mit Blobs und ArrayBuffers zurecht:</p>
<pre><code class="language-js">async function cacheAsset(url, blob) {
  const tx = db.transaction('assets', 'readwrite')
  tx.objectStore('assets').put({ url, blob, cached: Date.now() })
}

async function getCachedAsset(url) {
  const tx = db.transaction('assets', 'readonly')
  const result = await promisify(tx.objectStore('assets').get(url))
  return result?.blob || null
}
</code></pre>
<h2>9) Muster für Autosave</h2>
<pre><code class="language-js">class AutoSave {
  constructor(storage, interval = 60000) {
    this.storage = storage
    this.interval = interval
    this.timer = null
    this.dirty = false
  }
  
  markDirty() {
    this.dirty = true
  }
  
  start(getState) {
    this.timer = setInterval(async () =&gt; {
      if (this.dirty) {
        await this.storage.save('autosave', getState())
        this.dirty = false
        console.log('Autosaved')
      }
    }, this.interval)
  }
  
  stop() {
    clearInterval(this.timer)
  }
}
</code></pre>
<h2>10) Fehlerbehandlung und Rückfallwege</h2>
<pre><code class="language-js">async function safeLoad(slot, defaultState) {
  try {
    const saved = await loadGame(slot)
    if (saved) {
      // Validate/migrate old saves if needed
      return migrateSave(saved)
    }
  } catch (err) {
    console.warn('Failed to load save:', err)
  }
  return defaultState
}

function migrateSave(save) {
  // Handle old save formats
  if (!save.version) {
    save.version = 1
    save.settings = save.settings || {}
  }
  return save
}
</code></pre>
<h2>11) Spielstände vor dem Verdrängen schützen</h2>
<p>IndexedDB bleibt nicht garantiert erhalten. Standardmäßig nutzt eine Origin &quot;Best-Effort&quot;-Speicher, und der Browser darf ihn räumen, wenn die Platte volläuft oder, unter Safari/WebKit, nach längerer Zeit ohne Nutzerinteraktion mit deiner Seite. Bei Spielständen sind das genau die Daten, die du nicht verlieren willst.</p>
<p>Fordere persistenten Speicher an, damit der Browser deine Daten nicht ohne ausdrückliche Nutzeraktion löscht:</p>
<pre><code class="language-js">async function makeStoragePersistent() {
  if (navigator.storage &amp;&amp; navigator.storage.persist) {
    const persisted = await navigator.storage.persist()
    console.log(persisted ? 'Saves are protected from eviction' : 'Saves may be evicted under storage pressure')
    return persisted
  }
  return false
}
</code></pre>
<p>Browser entscheiden anhand von Interaktionssignalen, ob sie das gewähren (wie stark der Nutzer mit deiner Seite interagiert, ob sie als PWA installiert ist), nimm also nicht an, dass es immer klappt. Du kannst auch prüfen, wie viel Platz du hast, bevor du große Spielstände oder gecachte Assets schreibst:</p>
<pre><code class="language-js">async function checkStorage() {
  if (navigator.storage &amp;&amp; navigator.storage.estimate) {
    const { usage, quota } = await navigator.storage.estimate()
    console.log(`Using ${usage} of ${quota} bytes`)
  }
}
</code></pre>
<p>Beide APIs brauchen einen sicheren Kontext (HTTPS oder localhost).</p>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/pwa-offline-games">PWA für Offline-Spiele</a></li>
<li><a href="/de/tutorials/service-workers-caching">Service Worker fürs Spiel-Caching</a></li>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/streaming-asset-loading">Streaming von Assets</a> — IndexedDB als Asset-Cache nutzen</li>
<li><a href="/de/tutorials/analytics-web-games">Analytik für Web-Spiele</a> — Speicher- und Lademuster verfolgen, um Spielerverhalten zu verstehen</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">MDN: IndexedDB API</a> — vollständige API-Referenz</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB">MDN: Using IndexedDB</a> — Schritt-für-Schritt-Anleitung</li>
<li><a href="https://github.com/jakearchibald/idb">idb library</a> — ein winziger Promise-basierter IndexedDB-Wrapper</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Mobilfreundliche Web-Spiele]]></title>
            <link>https://app.cinevva.com/de/tutorials/mobile-friendly-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/mobile-friendly-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Web-Spiele auf Mobilgeräten zum Laufen bringen: Touch-Steuerung, Performance, Viewport, Akku und responsives Design. Erreich die größte Gaming-Plattform.]]></description>
            <content:encoded><![CDATA[<h1>Mobilfreundliche Web-Spiele</h1>
<p>Mobil ist die größte Gaming-Plattform. Mit ein paar Anpassungen läuft dein Web-Spiel auch auf Handys und Tablets richtig gut.</p>
<h2>1) Viewport einrichten</h2>
<p>Zoom verhindern und saubere Skalierung sicherstellen:</p>
<pre><code class="language-html">&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no&quot;&gt;
</code></pre>
<h2>2) Bildschirmfüllendes Canvas</h2>
<pre><code class="language-css">* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #000;
  touch-action: none; /* Prevent browser gestures */
}

#game {
  display: block;
  width: 100%;
  height: 100%;
}
</code></pre>
<p>Wenn du UI an der Viewport-Höhe statt an <code>height: 100%</code> ausrichtest, nimm die dynamischen Viewport-Einheiten <code>dvh</code>, <code>svh</code> und <code>lvh</code> statt <code>vh</code>. Sie berücksichtigen die ein- und ausfahrende Werkzeugleiste mobiler Browser, die <code>100vh</code> ignoriert (unter iOS ist <code>100vh</code> die Höhe bei ausgeblendeter Leiste, der untere Teil deines Layouts wird also abgeschnitten). Diese Einheiten haben Baseline Widely Available erreicht und werden in Chrome/Edge ab 108, Firefox ab 101 und Safari/iOS Safari ab 15.4 unterstützt, also von rund 92 % der Nutzer.</p>
<h2>3) Touch-Events behandeln</h2>
<pre><code class="language-js">const canvas = document.getElementById('game')

// Prevent default touch behaviors
canvas.addEventListener('touchstart', (e) =&gt; e.preventDefault(), { passive: false })
canvas.addEventListener('touchmove', (e) =&gt; e.preventDefault(), { passive: false })

// Track touches
const touches = new Map()

canvas.addEventListener('touchstart', (e) =&gt; {
  for (const touch of e.changedTouches) {
    touches.set(touch.identifier, {
      x: touch.clientX,
      y: touch.clientY,
      startX: touch.clientX,
      startY: touch.clientY,
    })
  }
})

canvas.addEventListener('touchmove', (e) =&gt; {
  for (const touch of e.changedTouches) {
    const t = touches.get(touch.identifier)
    if (t) {
      t.x = touch.clientX
      t.y = touch.clientY
    }
  }
})

canvas.addEventListener('touchend', (e) =&gt; {
  for (const touch of e.changedTouches) {
    touches.delete(touch.identifier)
  }
})
</code></pre>
<p>Soll dein Spiel auch mit Maus oder Stift funktionieren, zieh Pointer Events (<code>pointerdown</code>/<code>pointermove</code>/<code>pointerup</code>) statt Touch-Events in Betracht. MDN empfiehlt sie inzwischen als einheitliches, geräteunabhängiges Eingabemodell: Ein Codepfad deckt Touch, Maus und Stift ab, und Multi-Touch funktioniert weiterhin über <code>pointerId</code> (das Gegenstück zum oben genutzten <code>touch.identifier</code>). Für reine Touch-Spiele sind Touch-Events aber völlig in Ordnung.</p>
<h2>4) Virtueller Joystick</h2>
<pre><code class="language-js">class VirtualJoystick {
  constructor(x, y, radius) {
    this.baseX = x
    this.baseY = y
    this.radius = radius
    this.knobRadius = radius * 0.4
    this.active = false
    this.touchId = null
    this.dx = 0
    this.dy = 0
  }
  
  handleTouchStart(touch) {
    const dist = Math.hypot(touch.clientX - this.baseX, touch.clientY - this.baseY)
    if (dist &lt; this.radius) {
      this.active = true
      this.touchId = touch.identifier
      this.updatePosition(touch.clientX, touch.clientY)
    }
  }
  
  handleTouchMove(touch) {
    if (touch.identifier === this.touchId) {
      this.updatePosition(touch.clientX, touch.clientY)
    }
  }
  
  handleTouchEnd(touch) {
    if (touch.identifier === this.touchId) {
      this.active = false
      this.touchId = null
      this.dx = 0
      this.dy = 0
    }
  }
  
  updatePosition(x, y) {
    let dx = x - this.baseX
    let dy = y - this.baseY
    const dist = Math.hypot(dx, dy)
    
    if (dist &gt; this.radius) {
      dx = (dx / dist) * this.radius
      dy = (dy / dist) * this.radius
    }
    
    this.dx = dx / this.radius
    this.dy = dy / this.radius
  }
  
  draw(ctx) {
    // Base circle
    ctx.beginPath()
    ctx.arc(this.baseX, this.baseY, this.radius, 0, Math.PI * 2)
    ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'
    ctx.fill()
    
    // Knob
    const knobX = this.baseX + this.dx * this.radius
    const knobY = this.baseY + this.dy * this.radius
    ctx.beginPath()
    ctx.arc(knobX, knobY, this.knobRadius, 0, Math.PI * 2)
    ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'
    ctx.fill()
  }
  
  getInput() {
    return { x: this.dx, y: this.dy }
  }
}
</code></pre>
<h2>5) Touch-Buttons</h2>
<pre><code class="language-js">class TouchButton {
  constructor(x, y, radius, label) {
    this.x = x
    this.y = y
    this.radius = radius
    this.label = label
    this.pressed = false
    this.touchId = null
  }
  
  contains(px, py) {
    return Math.hypot(px - this.x, py - this.y) &lt; this.radius
  }
  
  handleTouchStart(touch) {
    if (this.contains(touch.clientX, touch.clientY)) {
      this.pressed = true
      this.touchId = touch.identifier
    }
  }
  
  handleTouchEnd(touch) {
    if (touch.identifier === this.touchId) {
      this.pressed = false
      this.touchId = null
    }
  }
  
  draw(ctx) {
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
    ctx.fillStyle = this.pressed ? 'rgba(255, 255, 255, 0.5)' : 'rgba(255, 255, 255, 0.2)'
    ctx.fill()
    
    ctx.font = 'bold 24px sans-serif'
    ctx.fillStyle = '#fff'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(this.label, this.x, this.y)
  }
}
</code></pre>
<h2>6) Ausrichtung behandeln</h2>
<pre><code class="language-js">function checkOrientation() {
  const isLandscape = window.innerWidth &gt; window.innerHeight
  
  if (!isLandscape) {
    showRotatePrompt()
  } else {
    hideRotatePrompt()
    resizeCanvas()
  }
}

window.addEventListener('resize', checkOrientation)
window.addEventListener('orientationchange', checkOrientation)

function showRotatePrompt() {
  document.getElementById('rotate-prompt').style.display = 'flex'
}

function hideRotatePrompt() {
  document.getElementById('rotate-prompt').style.display = 'none'
}
</code></pre>
<h2>7) Performance optimieren</h2>
<pre><code class="language-js">// Reduce resolution on low-end devices
function getDeviceScale() {
  const dpr = window.devicePixelRatio || 1
  const isLowEnd = navigator.hardwareConcurrency &lt;= 2
  return isLowEnd ? Math.min(dpr, 1.5) : Math.min(dpr, 2)
}

// Throttle expensive operations
let lastHeavyUpdate = 0
function maybeDoHeavyWork(timestamp) {
  if (timestamp - lastHeavyUpdate &gt; 100) { // 10 fps for heavy stuff
    doExpensiveCalculations()
    lastHeavyUpdate = timestamp
  }
}

// Use lower quality assets
function getAssetQuality() {
  const memory = navigator.deviceMemory || 4
  if (memory &lt;= 2) return 'low'
  if (memory &lt;= 4) return 'medium'
  return 'high'
}
</code></pre>
<h2>8) Rücksicht auf den Akku</h2>
<pre><code class="language-js">// Check battery and reduce effects
async function checkBattery() {
  if ('getBattery' in navigator) {
    const battery = await navigator.getBattery()
    
    if (battery.level &lt; 0.2 &amp;&amp; !battery.charging) {
      enableBatterySaver()
    }
    
    battery.addEventListener('levelchange', () =&gt; {
      if (battery.level &lt; 0.2 &amp;&amp; !battery.charging) {
        enableBatterySaver()
      }
    })
  }
}

function enableBatterySaver() {
  game.particleCount = Math.floor(game.particleCount * 0.5)
  game.targetFps = 30
  console.log('Battery saver enabled')
}
</code></pre>
<h2>9) Fullscreen-API</h2>
<pre><code class="language-js">async function requestFullscreen() {
  const elem = document.documentElement
  
  try {
    if (elem.requestFullscreen) {
      await elem.requestFullscreen()
    } else if (elem.webkitRequestFullscreen) {
      await elem.webkitRequestFullscreen()
    }
    
    // Lock orientation
    if (screen.orientation?.lock) {
      await screen.orientation.lock('landscape')
    }
  } catch (err) {
    console.warn('Fullscreen failed:', err)
  }
}

// Call on user interaction
document.getElementById('play-btn').addEventListener('click', () =&gt; {
  requestFullscreen()
  startGame()
})
</code></pre>
<p>Ein Vorbehalt: Die Fullscreen-API funktioniert auf dem iPhone nicht für allgemeine Elemente. iOS Safari bietet Vollbild nur für native Video-Elemente (über das nicht standardisierte <code>webkitEnterFullscreen</code>), <code>documentElement.requestFullscreen()</code> schlägt auf dem iPhone also fehl. Das <code>try/catch</code> oben fängt das sauber ab, entwirf dein Spiel also so, dass es auf dem iPhone auch ohne echtes Vollbild spielbar ist, und behandle Vollbild als Zugabe auf iPad, Android und Desktop, wo es unterstützt wird. Der Aufruf <code>screen.orientation.lock()</code> hat auf dem iPhone dieselbe Einschränkung, verlass dich also nicht darauf.</p>
<h2>10) Testcheckliste</h2>
<p>Vor dem Ausliefern:</p>
<ul>
<li><strong>Touch-Reaktion</strong> — auf echten Geräten testen, nicht nur in Emulatoren</li>
<li><strong>Verschiedene Bildschirmgrößen</strong> — Handy, Tablet, iPad Pro</li>
<li><strong>Beide Ausrichtungen</strong> — oder auf eine festlegen</li>
<li><strong>Schwache Geräte</strong> — auf 2 bis 3 Jahre alten Handys testen</li>
<li><strong>Langsame Netze</strong> — mit 3G-Simulation testen</li>
<li><strong>Safari unter iOS</strong> — hat Eigenheiten bei Audio und Vollbild</li>
<li><strong>Chrome unter Android</strong> — der häufigste Browser</li>
<li><strong>Unterbrechungen</strong> — Anrufe, Benachrichtigungen</li>
<li><strong>Speicherdruck</strong> — erholt es sich, nachdem der Tab im Hintergrund war?</li>
</ul>
<pre><code class="language-js">// Debug touch visualization
function drawTouchDebug(ctx) {
  for (const [id, touch] of touches) {
    ctx.beginPath()
    ctx.arc(touch.x, touch.y, 30, 0, Math.PI * 2)
    ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'
    ctx.fill()
    ctx.fillStyle = '#fff'
    ctx.fillText(id.toString(), touch.x, touch.y)
  }
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/game-input-handling">Eingaben in Spielen</a></li>
<li><a href="/de/tutorials/responsive-game-canvas">Responsives Spiel-Canvas</a></li>
<li><a href="/de/tutorials/pwa-offline-games">PWA für Offline-Spiele</a></li>
<li><a href="/de/tutorials/web-audio-api-games">Web-Audio-API für Spiele</a> — mit iOS-Audiobeschränkungen umgehen</li>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a> — Ladezeiten für mobile Verbindungen optimieren</li>
<li><a href="/de/tutorials/iframe-embedding-games">Spiele per Iframe einbetten</a> — was beim Einbetten auf Mobilgeräten zu beachten ist</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN: Touch events</a> — Multi-Touch behandeln</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API">MDN: Screen Orientation API</a> — Ausrichtung festlegen</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">MDN: Fullscreen API</a> — Vollbild auf Mobilgeräten</li>
<li><a href="https://web.dev/learn/design/">web.dev: Responsive design</a> — responsive Muster, die auch für Spiel-UI gelten</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pixel-Art-Rendering in Web-Spielen]]></title>
            <link>https://app.cinevva.com/de/tutorials/pixel-art-rendering</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/pixel-art-rendering</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Pixel-Art richtig rendern: scharfe Skalierung, keine Unschärfe, Farbpaletten und Animation. Damit dein Retro-Spiel gewollt aussieht.]]></description>
            <content:encoded><![CDATA[<h1>Pixel-Art-Rendering in Web-Spielen</h1>
<p>Pixel-Art sieht großartig aus, wenn sie richtig gerendert wird, und schrecklich, wenn sie verschwimmt. Dieses Tutorial zeigt die Techniken für knackige, authentische Pixelgrafik.</p>
<h2>1) Das Problem mit der Unschärfe</h2>
<p>Standardmäßig glätten Browser Bilder beim Skalieren. Für Pixel-Art ist das falsch:</p>
<pre><code class="language-css">/* CSS solution */
canvas {
  image-rendering: pixelated;
  image-rendering: crisp-edges; /* Firefox fallback */
}
</code></pre>
<h2>2) Einstellungen am Canvas-Kontext</h2>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

// Disable smoothing for crisp pixels
ctx.imageSmoothingEnabled = false
</code></pre>
<p>Das muss nach jeder Größenänderung des Canvas und jedem Kontextwechsel neu gesetzt werden.</p>
<h2>3) Ganzzahlige Skalierung</h2>
<p>Pixel-Art sieht bei ganzzahligen Skalierungen am besten aus (1x, 2x, 3x, 4x):</p>
<pre><code class="language-js">const GAME_WIDTH = 160
const GAME_HEIGHT = 144 // Game Boy resolution

function resize() {
  const maxScale = Math.floor(Math.min(
    window.innerWidth / GAME_WIDTH,
    window.innerHeight / GAME_HEIGHT
  ))
  const scale = Math.max(1, maxScale)
  
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
  canvas.style.width = GAME_WIDTH * scale + 'px'
  canvas.style.height = GAME_HEIGHT * scale + 'px'
  
  ctx.imageSmoothingEnabled = false
}
</code></pre>
<h2>4) Sprites an ganzzahligen Positionen zeichnen</h2>
<p>Subpixel-Positionen erzeugen Unschärfe:</p>
<pre><code class="language-js">// Bad: causes blur
ctx.drawImage(sprite, 10.5, 20.3)

// Good: crisp pixels
ctx.drawImage(sprite, Math.floor(10.5), Math.floor(20.3))

// Or round to nearest
ctx.drawImage(sprite, Math.round(player.x), Math.round(player.y))
</code></pre>
<h2>5) Animation mit Sprite-Sheets</h2>
<pre><code class="language-js">class PixelSprite {
  constructor(image, frameWidth, frameHeight) {
    this.image = image
    this.frameWidth = frameWidth
    this.frameHeight = frameHeight
    this.animations = {}
    this.currentAnim = null
    this.frame = 0
    this.timer = 0
  }
  
  addAnimation(name, frames, fps = 8) {
    this.animations[name] = { frames, duration: 1 / fps }
  }
  
  play(name) {
    if (this.currentAnim !== name) {
      this.currentAnim = name
      this.frame = 0
      this.timer = 0
    }
  }
  
  update(dt) {
    if (!this.currentAnim) return
    
    const anim = this.animations[this.currentAnim]
    this.timer += dt
    
    if (this.timer &gt;= anim.duration) {
      this.timer = 0
      this.frame = (this.frame + 1) % anim.frames.length
    }
  }
  
  draw(ctx, x, y, flipX = false) {
    if (!this.currentAnim) return
    
    const anim = this.animations[this.currentAnim]
    const frameIndex = anim.frames[this.frame]
    const sx = (frameIndex % 16) * this.frameWidth
    const sy = Math.floor(frameIndex / 16) * this.frameHeight
    
    ctx.save()
    if (flipX) {
      ctx.scale(-1, 1)
      x = -x - this.frameWidth
    }
    
    ctx.drawImage(
      this.image,
      sx, sy, this.frameWidth, this.frameHeight,
      Math.round(x), Math.round(y), this.frameWidth, this.frameHeight
    )
    ctx.restore()
  }
}

// Usage
const player = new PixelSprite(spriteSheet, 16, 16)
player.addAnimation('idle', [0, 1], 2)
player.addAnimation('walk', [2, 3, 4, 5], 8)
player.addAnimation('jump', [6], 1)
</code></pre>
<h2>6) Palettentausch</h2>
<p>Klassische Technik zum Umfärben von Sprites:</p>
<pre><code class="language-js">function createPaletteSwap(image, oldColors, newColors) {
  const canvas = document.createElement('canvas')
  canvas.width = image.width
  canvas.height = image.height
  const ctx = canvas.getContext('2d')
  
  ctx.drawImage(image, 0, 0)
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  const data = imageData.data
  
  for (let i = 0; i &lt; data.length; i += 4) {
    const r = data[i], g = data[i + 1], b = data[i + 2]
    
    for (let j = 0; j &lt; oldColors.length; j++) {
      const old = oldColors[j]
      if (r === old.r &amp;&amp; g === old.g &amp;&amp; b === old.b) {
        const newC = newColors[j]
        data[i] = newC.r
        data[i + 1] = newC.g
        data[i + 2] = newC.b
        break
      }
    }
  }
  
  ctx.putImageData(imageData, 0, 0)
  return canvas
}

// Create enemy variant
const blueEnemy = createPaletteSwap(
  enemySprite,
  [{ r: 255, g: 0, b: 0 }],    // Red
  [{ r: 0, g: 100, b: 255 }]   // Blue
)
</code></pre>
<h2>7) Tilemaps rendern</h2>
<pre><code class="language-js">class Tilemap {
  constructor(tileSize, tilesetImage) {
    this.tileSize = tileSize
    this.tileset = tilesetImage
    this.tilesPerRow = Math.floor(tilesetImage.width / tileSize)
    this.data = []
  }
  
  setData(width, height, tiles) {
    this.width = width
    this.height = height
    this.data = tiles
  }
  
  draw(ctx, cameraX = 0, cameraY = 0) {
    const startCol = Math.floor(cameraX / this.tileSize)
    const startRow = Math.floor(cameraY / this.tileSize)
    const endCol = startCol + Math.ceil(ctx.canvas.width / this.tileSize) + 1
    const endRow = startRow + Math.ceil(ctx.canvas.height / this.tileSize) + 1
    
    for (let row = startRow; row &lt; endRow; row++) {
      for (let col = startCol; col &lt; endCol; col++) {
        if (row &lt; 0 || row &gt;= this.height || col &lt; 0 || col &gt;= this.width) continue
        
        const tileIndex = this.data[row * this.width + col]
        if (tileIndex &lt; 0) continue
        
        const sx = (tileIndex % this.tilesPerRow) * this.tileSize
        const sy = Math.floor(tileIndex / this.tilesPerRow) * this.tileSize
        const dx = Math.round(col * this.tileSize - cameraX)
        const dy = Math.round(row * this.tileSize - cameraY)
        
        ctx.drawImage(
          this.tileset,
          sx, sy, this.tileSize, this.tileSize,
          dx, dy, this.tileSize, this.tileSize
        )
      }
    }
  }
}
</code></pre>
<h2>8) Umriss-Effekte</h2>
<p>Umrisse machen Sprites besser sichtbar:</p>
<pre><code class="language-js">function drawWithOutline(ctx, image, x, y, outlineColor = '#000') {
  const offsets = [[-1, 0], [1, 0], [0, -1], [0, 1]]
  
  // Draw outline
  ctx.globalCompositeOperation = 'source-over'
  for (const [ox, oy] of offsets) {
    ctx.drawImage(image, Math.round(x + ox), Math.round(y + oy))
  }
  
  // Apply color to outline
  ctx.globalCompositeOperation = 'source-in'
  ctx.fillStyle = outlineColor
  ctx.fillRect(x - 1, y - 1, image.width + 2, image.height + 2)
  
  // Draw sprite on top
  ctx.globalCompositeOperation = 'source-over'
  ctx.drawImage(image, Math.round(x), Math.round(y))
}
</code></pre>
<h2>9) Screenshake</h2>
<p>Klassischer Effekt fürs Spielgefühl:</p>
<pre><code class="language-js">const camera = {
  x: 0,
  y: 0,
  shakeIntensity: 0,
  shakeDuration: 0,
  
  shake(intensity, duration) {
    this.shakeIntensity = intensity
    this.shakeDuration = duration
  },
  
  update(dt) {
    if (this.shakeDuration &gt; 0) {
      this.shakeDuration -= dt
    }
  },
  
  getOffset() {
    if (this.shakeDuration &lt;= 0) return { x: 0, y: 0 }
    
    return {
      x: Math.round((Math.random() - 0.5) * 2 * this.shakeIntensity),
      y: Math.round((Math.random() - 0.5) * 2 * this.shakeIntensity),
    }
  }
}

// In render:
function render() {
  const shake = camera.getOffset()
  ctx.save()
  ctx.translate(shake.x, shake.y)
  // Draw everything...
  ctx.restore()
}

// On hit:
camera.shake(4, 0.2) // 4 pixel intensity, 0.2 seconds
</code></pre>
<h2>10) Beschränkte Farbpaletten</h2>
<p>Authentische Pixel-Art nutzt begrenzte Paletten:</p>
<pre><code class="language-js">const PALETTES = {
  gameboy: ['#0f380f', '#306230', '#8bac0f', '#9bbc0f'],
  nes: ['#000000', '#fcfcfc', '#f8f8f8', '#bcbcbc', /* ... */],
  pico8: [
    '#000000', '#1d2b53', '#7e2553', '#008751',
    '#ab5236', '#5f574f', '#c2c3c7', '#fff1e8',
    '#ff004d', '#ffa300', '#ffec27', '#00e436',
    '#29adff', '#83769c', '#ff77a8', '#ffccaa'
  ],
}

function quantizeColor(r, g, b, palette) {
  let minDist = Infinity
  let closest = palette[0]
  
  for (const color of palette) {
    const pr = parseInt(color.slice(1, 3), 16)
    const pg = parseInt(color.slice(3, 5), 16)
    const pb = parseInt(color.slice(5, 7), 16)
    
    const dist = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2
    if (dist &lt; minDist) {
      minDist = dist
      closest = color
    }
  }
  
  return closest
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a></li>
<li><a href="/de/tutorials/responsive-game-canvas">Responsives Spiel-Canvas</a></li>
<li><a href="/de/tutorials/streaming-asset-loading">Streaming von Assets</a></li>
<li><a href="/de/guides/game-assets-guide">Wo du kostenlose Game-Assets findest</a> — Sprite-Sheets, Pixel-Art-Pakete und Quellen für 2D-Assets</li>
<li><a href="/de/tutorials/text-rendering-games">Textdarstellung in Spielen</a> — Bitmap-Fonts, die zu deinem Pixel-Art-Stil passen</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pointer Lock für Shooter]]></title>
            <link>https://app.cinevva.com/de/tutorials/pointer-lock-fps</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/pointer-lock-fps</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Mausblick mit der Pointer-Lock-API umsetzen: Cursor einfangen, Bewegung auswerten und Ego-Steuerung für Web-Spiele bauen.]]></description>
            <content:encoded><![CDATA[<h1>Pointer Lock für Shooter</h1>
<p>Die Pointer-Lock-API fängt den Mauszeiger ein und ermöglicht flüssige Kamerasteuerung im Shooter-Stil. Unverzichtbar für Ego- und Third-Person-Shooter.</p>
<h2>1) Pointer Lock anfordern</h2>
<p>Pointer Lock verlangt eine Nutzergeste:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')

canvas.addEventListener('click', () =&gt; {
  canvas.requestPointerLock()
})
</code></pre>
<h2>Rohe Mauseingabe (Beschleunigung abschalten)</h2>
<p>Standardmäßig wendet der Browser die Mausbeschleunigung des Betriebssystems auf <code>movementX</code>/<code>movementY</code> an, ein schnelles Zucken und ein langsames Ziehen über dieselbe Strecke drehen die Kamera also unterschiedlich weit. Beim Zielen im Shooter willst du meist das Gegenteil: Dieselbe physische Strecke soll die Kamera immer gleich weit drehen. Fordere rohe, unbeschleunigte Bewegung an, indem du <code>unadjustedMovement: true</code> an <code>requestPointerLock()</code> übergibst:</p>
<pre><code class="language-js">canvas.addEventListener('click', async () =&gt; {
  try {
    await canvas.requestPointerLock({ unadjustedMovement: true })
  } catch (err) {
    // unadjustedMovement unsupported here; fall back to OS-adjusted deltas
    await canvas.requestPointerLock()
  }
})
</code></pre>
<p>Das moderne <code>requestPointerLock()</code> gibt ein Promise zurück, das bei Erfolg auflöst und bei Misserfolg rejectet, deshalb steht der Aufruf oben in einem <code>try/catch</code>. Chrome und Edge beachten <code>unadjustedMovement</code> seit Version 88, Safari unterstützt es seit 18.4 (März 2025), also seit derselben Version, die auch <code>requestPointerLock</code> auf ein Promise umgestellt hat. Ältere Browser, die kein Promise zurückgeben, funktionieren hier trotzdem, weil <code>await</code> einen Nicht-Promise-Wert verträgt und die unbekannte Option einfach ignoriert wird. Behalte den einfachen <code>requestPointerLock()</code>-Rückfall also immer.</p>
<h2>2) Den Sperrzustand erkennen</h2>
<pre><code class="language-js">document.addEventListener('pointerlockchange', () =&gt; {
  if (document.pointerLockElement === canvas) {
    console.log('Pointer locked')
    game.mouseLocked = true
  } else {
    console.log('Pointer unlocked')
    game.mouseLocked = false
  }
})

document.addEventListener('pointerlockerror', () =&gt; {
  console.error('Pointer lock failed')
})
</code></pre>
<h2>3) Mausbewegung auslesen</h2>
<p>Ist der Zeiger gesperrt, nutz <code>movementX</code> und <code>movementY</code>:</p>
<pre><code class="language-js">document.addEventListener('mousemove', (e) =&gt; {
  if (document.pointerLockElement !== canvas) return
  
  const sensitivity = 0.002
  camera.yaw -= e.movementX * sensitivity
  camera.pitch -= e.movementY * sensitivity
  
  // Clamp pitch to prevent flipping
  camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})
</code></pre>
<h2>4) Eine Klasse für die Ego-Kamera</h2>
<pre><code class="language-js">class FPSCamera {
  constructor() {
    this.position = { x: 0, y: 1.7, z: 0 } // Eye height
    this.yaw = 0      // Left/right rotation
    this.pitch = 0    // Up/down rotation
    this.sensitivity = 0.002
  }
  
  handleMouseMove(e) {
    this.yaw -= e.movementX * this.sensitivity
    this.pitch -= e.movementY * this.sensitivity
    this.pitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, this.pitch))
  }
  
  getForward() {
    return {
      x: Math.sin(this.yaw) * Math.cos(this.pitch),
      y: Math.sin(this.pitch),
      z: Math.cos(this.yaw) * Math.cos(this.pitch),
    }
  }
  
  getRight() {
    return {
      x: Math.cos(this.yaw),
      y: 0,
      z: -Math.sin(this.yaw),
    }
  }
  
  move(forward, right, dt, speed = 5) {
    const fwd = this.getForward()
    const rgt = this.getRight()
    
    // Move on XZ plane only
    this.position.x += (fwd.x * forward + rgt.x * right) * speed * dt
    this.position.z += (fwd.z * forward + rgt.z * right) * speed * dt
  }
  
  getViewMatrix() {
    const forward = this.getForward()
    const target = {
      x: this.position.x + forward.x,
      y: this.position.y + forward.y,
      z: this.position.z + forward.z,
    }
    return lookAt(this.position, target, { x: 0, y: 1, z: 0 })
  }
}
</code></pre>
<h2>5) WASD-Bewegung</h2>
<pre><code class="language-js">const input = {
  forward: false,
  backward: false,
  left: false,
  right: false,
  jump: false,
}

window.addEventListener('keydown', (e) =&gt; {
  switch (e.code) {
    case 'KeyW': input.forward = true; break
    case 'KeyS': input.backward = true; break
    case 'KeyA': input.left = true; break
    case 'KeyD': input.right = true; break
    case 'Space': input.jump = true; break
  }
})

window.addEventListener('keyup', (e) =&gt; {
  switch (e.code) {
    case 'KeyW': input.forward = false; break
    case 'KeyS': input.backward = false; break
    case 'KeyA': input.left = false; break
    case 'KeyD': input.right = false; break
    case 'Space': input.jump = false; break
  }
})

function update(dt) {
  const moveForward = (input.forward ? 1 : 0) - (input.backward ? 1 : 0)
  const moveRight = (input.right ? 1 : 0) - (input.left ? 1 : 0)
  
  camera.move(moveForward, moveRight, dt)
}
</code></pre>
<h2>6) Ein vollständiger Ego-Controller</h2>
<pre><code class="language-js">class FPSController {
  constructor(canvas) {
    this.canvas = canvas
    this.camera = new FPSCamera()
    this.locked = false
    this.speed = 5
    this.sprintMultiplier = 1.5
    
    this.input = {
      forward: false, backward: false,
      left: false, right: false,
      jump: false, sprint: false,
    }
    
    this.velocity = { x: 0, y: 0, z: 0 }
    this.onGround = true
    this.gravity = -20
    this.jumpSpeed = 8
    
    this.bindEvents()
  }
  
  bindEvents() {
    this.canvas.addEventListener('click', () =&gt; {
      this.canvas.requestPointerLock()
    })
    
    document.addEventListener('pointerlockchange', () =&gt; {
      this.locked = document.pointerLockElement === this.canvas
    })
    
    document.addEventListener('mousemove', (e) =&gt; {
      if (this.locked) {
        this.camera.handleMouseMove(e)
      }
    })
    
    window.addEventListener('keydown', (e) =&gt; this.handleKey(e.code, true))
    window.addEventListener('keyup', (e) =&gt; this.handleKey(e.code, false))
  }
  
  handleKey(code, pressed) {
    switch (code) {
      case 'KeyW': this.input.forward = pressed; break
      case 'KeyS': this.input.backward = pressed; break
      case 'KeyA': this.input.left = pressed; break
      case 'KeyD': this.input.right = pressed; break
      case 'Space': this.input.jump = pressed; break
      case 'ShiftLeft': this.input.sprint = pressed; break
    }
  }
  
  update(dt) {
    if (!this.locked) return
    
    const speed = this.speed * (this.input.sprint ? this.sprintMultiplier : 1)
    
    // Horizontal movement
    const moveForward = (this.input.forward ? 1 : 0) - (this.input.backward ? 1 : 0)
    const moveRight = (this.input.right ? 1 : 0) - (this.input.left ? 1 : 0)
    
    const forward = this.camera.getForward()
    const right = this.camera.getRight()
    
    this.velocity.x = (forward.x * moveForward + right.x * moveRight) * speed
    this.velocity.z = (forward.z * moveForward + right.z * moveRight) * speed
    
    // Jumping
    if (this.input.jump &amp;&amp; this.onGround) {
      this.velocity.y = this.jumpSpeed
      this.onGround = false
    }
    
    // Gravity
    if (!this.onGround) {
      this.velocity.y += this.gravity * dt
    }
    
    // Apply velocity
    this.camera.position.x += this.velocity.x * dt
    this.camera.position.y += this.velocity.y * dt
    this.camera.position.z += this.velocity.z * dt
    
    // Simple ground collision
    if (this.camera.position.y &lt; 1.7) {
      this.camera.position.y = 1.7
      this.velocity.y = 0
      this.onGround = true
    }
  }
}
</code></pre>
<h2>7) Fadenkreuz</h2>
<pre><code class="language-js">function drawCrosshair(ctx) {
  const cx = ctx.canvas.width / 2
  const cy = ctx.canvas.height / 2
  const size = 10
  const gap = 4
  
  ctx.strokeStyle = '#fff'
  ctx.lineWidth = 2
  
  // Top
  ctx.beginPath()
  ctx.moveTo(cx, cy - gap)
  ctx.lineTo(cx, cy - gap - size)
  ctx.stroke()
  
  // Bottom
  ctx.beginPath()
  ctx.moveTo(cx, cy + gap)
  ctx.lineTo(cx, cy + gap + size)
  ctx.stroke()
  
  // Left
  ctx.beginPath()
  ctx.moveTo(cx - gap, cy)
  ctx.lineTo(cx - gap - size, cy)
  ctx.stroke()
  
  // Right
  ctx.beginPath()
  ctx.moveTo(cx + gap, cy)
  ctx.lineTo(cx + gap + size, cy)
  ctx.stroke()
}
</code></pre>
<h2>8) Schießen mit den Maustasten</h2>
<pre><code class="language-js">document.addEventListener('mousedown', (e) =&gt; {
  if (!document.pointerLockElement) return
  
  if (e.button === 0) {
    // Left click - primary fire
    weapon.fire()
  } else if (e.button === 2) {
    // Right click - aim down sights
    weapon.aimDownSights(true)
  }
})

document.addEventListener('mouseup', (e) =&gt; {
  if (e.button === 2) {
    weapon.aimDownSights(false)
  }
})

// Prevent context menu
canvas.addEventListener('contextmenu', (e) =&gt; e.preventDefault())
</code></pre>
<h2>9) Einstellungen zur Empfindlichkeit</h2>
<pre><code class="language-js">class Settings {
  constructor() {
    this.mouseSensitivity = parseFloat(localStorage.getItem('sensitivity') || '1.0')
    this.invertY = localStorage.getItem('invertY') === 'true'
  }
  
  save() {
    localStorage.setItem('sensitivity', this.mouseSensitivity.toString())
    localStorage.setItem('invertY', this.invertY.toString())
  }
}

// Apply in mouse handler
document.addEventListener('mousemove', (e) =&gt; {
  if (!locked) return
  
  const sens = settings.mouseSensitivity * 0.002
  camera.yaw -= e.movementX * sens
  camera.pitch -= e.movementY * sens * (settings.invertY ? -1 : 1)
})
</code></pre>
<h2>10) Hinweis zum Verlassen</h2>
<p>Zeig eine Anleitung, wie man Pointer Lock verlässt:</p>
<pre><code class="language-js">function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '&lt;p&gt;Press ESC to unlock mouse&lt;/p&gt;'
    ui.style.opacity = '0.5'
    setTimeout(() =&gt; ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '&lt;p&gt;Click to play&lt;/p&gt;'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)
</code></pre>
<p>Eines solltest du zum Verlassen wissen: Drückt der Spieler ESC, um Pointer Lock zu beenden, verlangt der Browser eine frische Nutzergeste (einen Klick), bevor du erneut sperren kannst, und genau dafür gibt es das Overlay &quot;Click to play&quot;. Ruft dein eigener Code dagegen <code>document.exitPointerLock()</code> auf (etwa um ein Ingame-Menü zu öffnen), braucht es danach keine neue Geste, du kannst also beim Schließen des Menüs programmatisch wieder sperren. Wiederholtes Drücken von ESC kann dem Browser außerdem signalisieren, erneutes Sperren zu verweigern, bis eine bewusstere Nutzeraktion kommt, versuch also kein automatisches Wiedersperren bei <code>pointerlockchange</code>.</p>
<h2>Hinweise zu Iframes</h2>
<p>Pointer Lock in Iframes verlangt das Attribut <code>allow=&quot;pointer-lock&quot;</code>:</p>
<pre><code class="language-html">&lt;iframe 
  src=&quot;game.html&quot; 
  allow=&quot;pointer-lock; fullscreen&quot;
&gt;&lt;/iframe&gt;
</code></pre>
<p>Prüfe, ob Pointer Lock verfügbar ist:</p>
<pre><code class="language-js">if (!document.pointerLockElement &amp;&amp; !('requestPointerLock' in canvas)) {
  showMessage('Mouse capture not available')
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/game-input-handling">Eingaben in Spielen</a></li>
<li><a href="/de/tutorials/gamepad-api">Gamepad-API</a></li>
<li><a href="/de/tutorials/webgl-fundamentals-for-games">WebGL-Grundlagen</a></li>
<li><a href="/de/tutorials/webxr-basics">WebXR-Grundlagen</a> — VR-Steuerung für Ego-Erlebnisse</li>
<li><a href="/de/tutorials/webgpu-getting-started">WebGPU-Einstieg</a> — modernes GPU-Rendering für Shooter</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API">MDN: Pointer Lock API</a> — vollständige API-Referenz</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX">MDN: MouseEvent.movementX</a> — Mausdeltas auslesen</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[PWA für Web-Spiele offline]]></title>
            <link>https://app.cinevva.com/de/tutorials/pwa-offline-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/pwa-offline-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Dein Spiel offline lauffähig machen mit PWA: Manifest, Service Worker, Caching-Strategien und Installationsaufforderung. Überall spielen, jederzeit.]]></description>
            <content:encoded><![CDATA[<h1>PWA für Web-Spiele offline</h1>
<p>Progressive Web Apps lassen Spieler dein Spiel installieren und offline spielen. Dieses Tutorial zeigt, wie du einem Web-Spiel PWA-Unterstützung gibst.</p>
<h2>1) Web App Manifest</h2>
<p>Leg <code>manifest.json</code> an:</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;My Awesome Game&quot;,
  &quot;short_name&quot;: &quot;AwesomeGame&quot;,
  &quot;description&quot;: &quot;An awesome game you can play offline&quot;,
  &quot;start_url&quot;: &quot;/&quot;,
  &quot;display&quot;: &quot;fullscreen&quot;,
  &quot;orientation&quot;: &quot;landscape&quot;,
  &quot;background_color&quot;: &quot;#1a1a2e&quot;,
  &quot;theme_color&quot;: &quot;#4ade80&quot;,
  &quot;icons&quot;: [
    {
      &quot;src&quot;: &quot;/icons/icon-192.png&quot;,
      &quot;sizes&quot;: &quot;192x192&quot;,
      &quot;type&quot;: &quot;image/png&quot;
    },
    {
      &quot;src&quot;: &quot;/icons/icon-512.png&quot;,
      &quot;sizes&quot;: &quot;512x512&quot;,
      &quot;type&quot;: &quot;image/png&quot;
    },
    {
      &quot;src&quot;: &quot;/icons/icon-maskable.png&quot;,
      &quot;sizes&quot;: &quot;512x512&quot;,
      &quot;type&quot;: &quot;image/png&quot;,
      &quot;purpose&quot;: &quot;maskable&quot;
    }
  ]
}
</code></pre>
<p>Verlink es in deinem HTML:</p>
<pre><code class="language-html">&lt;link rel=&quot;manifest&quot; href=&quot;/manifest.json&quot;&gt;
&lt;meta name=&quot;theme-color&quot; content=&quot;#4ade80&quot;&gt;
&lt;link rel=&quot;apple-touch-icon&quot; href=&quot;/icons/icon-192.png&quot;&gt;
</code></pre>
<h2>2) Einfacher Service Worker</h2>
<p>Leg <code>sw.js</code> an:</p>
<pre><code class="language-js">const CACHE_NAME = 'game-v1'

const ASSETS = [
  '/',
  '/index.html',
  '/game.js',
  '/style.css',
  '/assets/sprites.png',
  '/assets/sounds/jump.mp3',
  '/assets/sounds/music.mp3',
]

// Install: cache assets
self.addEventListener('install', (event) =&gt; {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) =&gt; {
      return cache.addAll(ASSETS)
    })
  )
})

// Activate: clean old caches
self.addEventListener('activate', (event) =&gt; {
  event.waitUntil(
    caches.keys().then((keys) =&gt; {
      return Promise.all(
        keys.filter(key =&gt; key !== CACHE_NAME)
            .map(key =&gt; caches.delete(key))
      )
    })
  )
})

// Fetch: serve from cache, fall back to network
self.addEventListener('fetch', (event) =&gt; {
  event.respondWith(
    caches.match(event.request).then((cached) =&gt; {
      return cached || fetch(event.request)
    })
  )
})
</code></pre>
<h2>3) Den Service Worker registrieren</h2>
<pre><code class="language-js">if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () =&gt; {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js')
      console.log('SW registered:', registration.scope)
    } catch (err) {
      console.error('SW registration failed:', err)
    }
  })
}
</code></pre>
<h2>4) Cache zuerst, Netz im Hintergrund</h2>
<p>Für Spiele besser, weil es schnell lädt und im Hintergrund aktualisiert:</p>
<pre><code class="language-js">self.addEventListener('fetch', (event) =&gt; {
  event.respondWith(
    caches.open(CACHE_NAME).then(async (cache) =&gt; {
      const cached = await cache.match(event.request)
      
      // Start network fetch in background
      const fetchPromise = fetch(event.request).then((response) =&gt; {
        if (response.ok) {
          cache.put(event.request, response.clone())
        }
        return response
      }).catch(() =&gt; null)
      
      // Return cached immediately, or wait for network
      return cached || fetchPromise
    })
  )
})
</code></pre>
<h2>5) Versionierter Cache für Updates</h2>
<pre><code class="language-js">const CACHE_VERSION = 'v2'
const STATIC_CACHE = `static-${CACHE_VERSION}`
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`

const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/game.js',
  // ... core assets that rarely change
]

self.addEventListener('install', (event) =&gt; {
  self.skipWaiting() // Activate immediately
  
  event.waitUntil(
    caches.open(STATIC_CACHE).then(cache =&gt; cache.addAll(STATIC_ASSETS))
  )
})

self.addEventListener('activate', (event) =&gt; {
  event.waitUntil(
    caches.keys().then(keys =&gt; {
      return Promise.all(
        keys.filter(key =&gt; !key.includes(CACHE_VERSION))
            .map(key =&gt; caches.delete(key))
      )
    })
  )
  
  clients.claim() // Take control immediately
})
</code></pre>
<h2>6) Mit Spiel-Updates umgehen</h2>
<p>Sag Spielern Bescheid, wenn ein Update bereitsteht:</p>
<pre><code class="language-js">// In main app
let refreshing = false

navigator.serviceWorker.addEventListener('controllerchange', () =&gt; {
  if (!refreshing) {
    refreshing = true
    showUpdateNotification()
  }
})

function showUpdateNotification() {
  const banner = document.createElement('div')
  banner.innerHTML = `
    &lt;p&gt;Game updated! Refresh to get the latest version.&lt;/p&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Refresh&lt;/button&gt;
  `
  banner.className = 'update-banner'
  document.body.appendChild(banner)
}
</code></pre>
<h2>7) Offline-Erkennung</h2>
<pre><code class="language-js">function updateOnlineStatus() {
  if (navigator.onLine) {
    hideOfflineBanner()
    syncGameData()
  } else {
    showOfflineBanner()
  }
}

window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)

function showOfflineBanner() {
  document.getElementById('offline-banner').style.display = 'block'
}

function hideOfflineBanner() {
  document.getElementById('offline-banner').style.display = 'none'
}
</code></pre>
<h2>8) Installationsaufforderung</h2>
<pre><code class="language-js">let deferredPrompt = null

window.addEventListener('beforeinstallprompt', (e) =&gt; {
  e.preventDefault()
  deferredPrompt = e
  showInstallButton()
})

function showInstallButton() {
  const btn = document.getElementById('install-btn')
  btn.style.display = 'block'
  btn.addEventListener('click', installApp)
}

async function installApp() {
  if (!deferredPrompt) return
  
  deferredPrompt.prompt()
  const { outcome } = await deferredPrompt.userChoice
  
  console.log('Install prompt outcome:', outcome)
  deferredPrompt = null
  document.getElementById('install-btn').style.display = 'none'
}

window.addEventListener('appinstalled', () =&gt; {
  console.log('App installed!')
  deferredPrompt = null
})
</code></pre>
<p>Unter iOS und iPadOS wird das Event <code>beforeinstallprompt</code> nie ausgelöst, der Installations-Button oben erscheint also nur in Chromium-Browsern. Auf iPhone und iPad installiert man über Teilen und dann &quot;Zum Home-Bildschirm&quot;. Eine Änderung aus 2026 ist wissenswert: Ab Safari 26 (iOS 26 / iPadOS 26) öffnet jede zum Home-Bildschirm hinzugefügte Seite standardmäßig als Web-App, mit einem Schalter &quot;Als Web-App öffnen&quot;, der von Haus aus aktiv ist. Eine Installierbarkeitsanforderung gibt es unter iOS nicht mehr, ein Manifest und ein Service Worker sorgen aber weiterhin für ein deutlich besseres Offline-Erlebnis.</p>
<h2>9) Background Sync für Bestenlisten</h2>
<p>Zur Unterstützung: Die Background Sync API gibt es nur in Chromium-Browsern (Chrome, Edge, Opera, Samsung Internet). Firefox und Safari (auch iOS) unterstützen sie nicht, prüfe also immer auf Verfügbarkeit und halte einen Rückfallweg bereit, der Punktstände beim nächsten normalen App-Start überträgt.</p>
<pre><code class="language-js">// In service worker
self.addEventListener('sync', (event) =&gt; {
  if (event.tag === 'sync-scores') {
    event.waitUntil(syncScores())
  }
})

async function syncScores() {
  const db = await openDB('game', 1)
  const pendingScores = await db.getAll('pending-scores')
  
  for (const score of pendingScores) {
    try {
      await fetch('/api/scores', {
        method: 'POST',
        body: JSON.stringify(score),
        headers: { 'Content-Type': 'application/json' }
      })
      await db.delete('pending-scores', score.id)
    } catch (err) {
      // Will retry on next sync
      break
    }
  }
}

// In main app
async function submitScore(score) {
  try {
    await fetch('/api/scores', { method: 'POST', body: JSON.stringify(score) })
  } catch {
    // Save for later sync
    const db = await openDB('game', 1)
    await db.add('pending-scores', { ...score, id: Date.now() })
    
    if ('serviceWorker' in navigator &amp;&amp; 'sync' in window.registration) {
      await navigator.serviceWorker.ready
      await registration.sync.register('sync-scores')
    }
  }
}
</code></pre>
<h2>10) PWA testen</h2>
<p><strong>Chrome DevTools:</strong></p>
<ul>
<li>Application &gt; Service Workers</li>
<li>Application &gt; Manifest</li>
<li>Application &gt; Cache Storage</li>
<li>Network &gt; Checkbox Offline</li>
</ul>
<p><strong>Lighthouse-Audit:</strong></p>
<ul>
<li>PWA-Audit ausführen</li>
<li>Installierbarkeit prüfen</li>
<li>Offline-Fähigkeit prüfen</li>
</ul>
<p><strong>Test auf echten Geräten:</strong></p>
<ul>
<li>Auf dem Home-Bildschirm des Handys installieren</li>
<li>Flugmodus aktivieren</li>
<li>Alle Funktionen offline testen</li>
</ul>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/service-workers-caching">Service Worker fürs Spiel-Caching</a></li>
<li><a href="/de/tutorials/indexeddb-game-saves">Spielstände in IndexedDB</a></li>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/mobile-friendly-web-games">Mobilfreundliche Web-Spiele</a> — Touch-Steuerung und Viewport für PWA-Spiele</li>
<li><a href="/de/tutorials/streaming-asset-loading">Streaming von Assets</a> — Ladestrategien, die mit Service-Worker-Caching zusammenspielen</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps">MDN: Progressive Web Apps</a> — vollständige PWA-Dokumentation</li>
<li><a href="https://web.dev/learn/pwa/">web.dev: Learn PWA</a> — Googles Lernpfad zu PWAs</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Manifest">MDN: Web App Manifest</a> — Referenz zur Manifest-Datei</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Responsives Spiel-Canvas, das sauber skaliert]]></title>
            <link>https://app.cinevva.com/de/tutorials/responsive-game-canvas</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/responsive-game-canvas</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Dein Spiel-Canvas responsiv machen: pixelgenaue Skalierung, Letterboxing und Resize-Events ohne Ruckeln.]]></description>
            <content:encoded><![CDATA[<h1>Responsives Spiel-Canvas</h1>
<p>Ein Canvas, das nicht sauber skaliert, sieht unscharf aus oder bricht auf anderen Bildschirmgrößen. Ich habe dieses Problem öfter debuggt, als ich zählen kann, lass mich dir die Mühe ersparen.</p>
<p><strong>Probier verschiedene Skalierungsmodi:</strong></p>
<ResponsiveDemo />
<h2>Das Kernproblem</h2>
<p>Ein Canvas hat zwei Größen, die man leicht verwechselt. Die CSS-Größe bestimmt, wie groß es auf dem Bildschirm erscheint. Die interne Auflösung bestimmt, wie viele Pixel du tatsächlich zeichnest. Passen die nicht zusammen, bekommst du Unschärfe oder Verzerrung.</p>
<p>Auf einem Retina-Display sagt dein CSS vielleicht 800x600, der Bildschirm hat aber 1600x1200 physische Pixel. Liegt die interne Auflösung deines Canvas nur bei 800x600, skaliert der Browser sie hoch und alles wirkt matschig.</p>
<h2>Grundaufbau für Responsivität</h2>
<p>So füllt ein Canvas das Fenster und bleibt scharf:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

function resize() {
  const dpr = window.devicePixelRatio || 1
  const width = window.innerWidth
  const height = window.innerHeight
  
  // Set internal resolution to match physical pixels
  canvas.width = width * dpr
  canvas.height = height * dpr
  
  // Set CSS size to match logical pixels
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  
  // Scale context so you can draw in logical pixels
  ctx.scale(dpr, dpr)
}

window.addEventListener('resize', resize)
resize()
</code></pre>
<p>Die Zeile <code>ctx.scale(dpr, dpr)</code> ist der Kniff. Danach kannst du an Position (100, 100) zeichnen und es landet unabhängig vom DPR an der richtigen Stelle. Das Canvas rendert intern auf einem 2x-Display mit 200x200, dein Code muss davon nichts wissen.</p>
<p>Eine Falle: <code>ctx.scale</code> ist kumulativ. Rufst du resize mehrfach auf, skalierst du immer weiter hoch. Setz entweder vorher die Transformation mit <code>ctx.setTransform(1, 0, 0, 1, 0, 0)</code> zurück oder skaliere nur einmal.</p>
<h2>Festes Seitenverhältnis mit Letterboxing</h2>
<p>Viele Spiele brauchen ein bestimmtes Seitenverhältnis. Du willst dein 16:9-Spiel nicht auf einen 21:9-Ultrawide-Monitor gezerrt sehen. Letterboxing ergänzt schwarze Balken und erhält das Verhältnis.</p>
<pre><code class="language-js">const GAME_WIDTH = 1920
const GAME_HEIGHT = 1080
const ASPECT = GAME_WIDTH / GAME_HEIGHT

function resizeWithLetterbox() {
  const windowWidth = window.innerWidth
  const windowHeight = window.innerHeight
  const windowAspect = windowWidth / windowHeight
  
  let width, height
  
  if (windowAspect &gt; ASPECT) {
    // Window is wider than game, add bars on sides
    height = windowHeight
    width = height * ASPECT
  } else {
    // Window is taller than game, add bars on top/bottom
    width = windowWidth
    height = width / ASPECT
  }
  
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  canvas.style.position = 'absolute'
  canvas.style.left = (windowWidth - width) / 2 + 'px'
  canvas.style.top = (windowHeight - height) / 2 + 'px'
  
  // Internal resolution stays fixed
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
}
</code></pre>
<p>Damit die Letterbox-Balken sichtbar werden, setz den Hintergrund des Body auf Schwarz:</p>
<pre><code class="language-css">body {
  margin: 0;
  background: #000;
  overflow: hidden;
}
</code></pre>
<h2>Ganzzahlige Skalierung für Pixel-Art</h2>
<p>Pixel-Art wirkt bei ganzzahligen Skalierungen (1x, 2x, 3x) knackig und bei gebrochenen wie 2,7x unscharf. Selbst mit <code>image-rendering: pixelated</code> heißt eine 2,5-fache Skalierung, dass manche Pixel 2 Bildschirmpixel breit sind und andere 3.</p>
<pre><code class="language-js">const GAME_WIDTH = 320
const GAME_HEIGHT = 180

function resizePixelPerfect() {
  const maxWidth = window.innerWidth
  const maxHeight = window.innerHeight
  
  // Find largest integer scale that fits
  const scaleX = Math.floor(maxWidth / GAME_WIDTH)
  const scaleY = Math.floor(maxHeight / GAME_HEIGHT)
  const scale = Math.max(1, Math.min(scaleX, scaleY))
  
  const width = GAME_WIDTH * scale
  const height = GAME_HEIGHT * scale
  
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  canvas.style.imageRendering = 'pixelated'
}
</code></pre>
<p>Der Kompromiss ist, dass dein Spiel den Bildschirm nicht exakt füllt. Auf einem 1920x1080-Display ergibt ein 320x180-Spiel eine 5-fache Skalierung (1600x900) mit schwarzen Rändern. Manche Spieler mögen das, andere wollen ein bildschirmfüllendes Spiel, selbst wenn es leicht unscharf ist.</p>
<h2>Skalierung eines WebGL-Canvas</h2>
<p>Bei WebGL musst du zusätzlich zur Canvas-Größe das Viewport aktualisieren:</p>
<pre><code class="language-js">function resizeGL(gl, canvas) {
  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth * dpr
  const height = canvas.clientHeight * dpr
  
  if (canvas.width !== width || canvas.height !== height) {
    canvas.width = width
    canvas.height = height
    gl.viewport(0, 0, width, height)
    return true // Resized, you may need to update projection matrices
  }
  return false
}
</code></pre>
<p>Ruf das am Anfang deiner Render-Schleife auf, nicht nur bei Resize-Events. Manche Browser ändern den DPR, wenn du ein Fenster zwischen Monitoren ziehst, und das willst du mitbekommen.</p>
<h2>Resize-Events entprellen</h2>
<p>Resize-Events feuern in schneller Folge, während der Nutzer am Fensterrand zieht. Ist deine Resize-Funktion teuer, ruckelt es.</p>
<pre><code class="language-js">let resizeTimeout
window.addEventListener('resize', () =&gt; {
  clearTimeout(resizeTimeout)
  resizeTimeout = setTimeout(resize, 100)
})
</code></pre>
<p>Die Verzögerung von 100 ms heißt, dass das Resize erst passiert, wenn der Nutzer aufhört zu ziehen. Für weicheres Verhalten während des Ziehens nimmst du stattdessen ResizeObserver:</p>
<pre><code class="language-js">const observer = new ResizeObserver(entries =&gt; {
  resize()
})
observer.observe(document.body)
</code></pre>
<p>ResizeObserver feuert seltener und wird vom Browser besser optimiert.</p>
<h2>Vollbild</h2>
<p>Mit der Fullscreen-API übernimmst du den ganzen Bildschirm:</p>
<pre><code class="language-js">async function toggleFullscreen() {
  if (!document.fullscreenElement) {
    await canvas.requestFullscreen()
  } else {
    await document.exitFullscreen()
  }
}

document.addEventListener('fullscreenchange', resize)
</code></pre>
<p>Du musst <code>requestFullscreen</code> als Reaktion auf eine Nutzergeste wie einen Klick aufrufen. Sonst blockieren Browser es. Und denk daran, beim Wechsel in den Vollbildmodus und zurück neu zu skalieren, weil sich der verfügbare Platz ändert.</p>
<p>Ein Plattformvorbehalt: Stand Mitte 2026 deckt die Fullscreen-API auf dem iPhone in Safari weiterhin kein Element-Vollbild ab. requestFullscreen funktioniert auf Desktop-Browsern und unter iPadOS, auf dem iPhone aber nicht für beliebige Elemente wie ein Canvas (nur natives Video-Vollbild geht). Prüf mit <code>if (canvas.requestFullscreen)</code> auf Verfügbarkeit und weich sauber aus, indem du das Canvas etwa auf den sichtbaren Viewport skalierst oder Nutzer bittest, das Spiel als PWA zum Home-Bildschirm hinzuzufügen.</p>
<h2>Alles zusammen</h2>
<p>Diese Klasse deckt die üblichen Fälle ab:</p>
<pre><code class="language-js">class ResponsiveCanvas {
  constructor(canvas, options = {}) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.gameWidth = options.width || 1280
    this.gameHeight = options.height || 720
    this.pixelArt = options.pixelArt || false
    this.letterbox = options.letterbox !== false
    
    this.bind()
    this.resize()
  }
  
  bind() {
    window.addEventListener('resize', () =&gt; this.resize())
    document.addEventListener('fullscreenchange', () =&gt; this.resize())
  }
  
  resize() {
    const aspect = this.gameWidth / this.gameHeight
    let width = window.innerWidth
    let height = window.innerHeight
    
    if (this.letterbox) {
      if (width / height &gt; aspect) {
        width = height * aspect
      } else {
        height = width / aspect
      }
    }
    
    if (this.pixelArt) {
      const scale = Math.max(1, Math.floor(Math.min(
        window.innerWidth / this.gameWidth,
        window.innerHeight / this.gameHeight
      )))
      width = this.gameWidth * scale
      height = this.gameHeight * scale
    }
    
    this.canvas.width = this.gameWidth
    this.canvas.height = this.gameHeight
    this.canvas.style.width = width + 'px'
    this.canvas.style.height = height + 'px'
    this.canvas.style.position = 'absolute'
    this.canvas.style.left = (window.innerWidth - width) / 2 + 'px'
    this.canvas.style.top = (window.innerHeight - height) / 2 + 'px'
    
    if (this.pixelArt) {
      this.canvas.style.imageRendering = 'pixelated'
    }
  }
  
  get width() { return this.gameWidth }
  get height() { return this.gameHeight }
}
</code></pre>
<p>So nutzt du sie:</p>
<pre><code class="language-js">const responsive = new ResponsiveCanvas(document.getElementById('game'), {
  width: 320,
  height: 180,
  pixelArt: true,
  letterbox: true
})

// In your game code, use responsive.width and responsive.height
</code></pre>
<h2>Typische Stolperfallen</h2>
<p>Ein paar Dinge, über die Leute stolpern:</p>
<p><strong>Das Canvas wird beim Resize geleert.</strong> Setzt du <code>canvas.width</code> oder <code>canvas.height</code>, leert sich das Canvas. Zeichnest du nicht in jedem Frame neu, musst du nach dem Resize neu zeichnen.</p>
<p><strong>Der DPR kann sich ändern.</strong> Zieht der Nutzer das Browserfenster von einem Retina- auf ein Nicht-Retina-Display, ändert sich der DPR. Prüf ihn in deiner Render-Schleife, nicht nur beim Resize.</p>
<p><strong>Viewport-Eigenheiten in iOS Safari.</strong> Die Viewport-Höhe ändert sich, wenn die Adressleiste ein- und ausgeblendet wird. Nutz <code>window.visualViewport</code> für genauere Größen auf Mobilgeräten.</p>
<p><strong>Der Kontextzustand wird zurückgesetzt.</strong> Das Setzen der Canvas-Maße setzt den Kontextzustand zurück, inklusive Transformationen, Füllstile und <code>imageSmoothingEnabled</code>. Setz sie nach dem Resize neu.</p>
<h2>Mehr Ressourcen</h2>
<p><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a> behandelt die Grundlagen von Update- und Render-Schleifen.</p>
<p><a href="/de/tutorials/pixel-art-rendering">Pixel-Art-Rendering</a> geht tiefer auf knackige Pixelgrafik ein.</p>
<p><a href="/de/tutorials/mobile-friendly-web-games">Mobilfreundliche Web-Spiele</a> behandelt Touch-Eingaben und Viewport-Probleme.</p>
<p><a href="/de/tutorials/webgl-fundamentals-for-games">WebGL-Grundlagen</a> behandelt Viewport und Canvas-Setup für WebGL-Kontexte.</p>
<p><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a> behandelt die Optimierung der Canvas-Größe für Performance.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Service Worker fürs Caching von Spiel-Assets]]></title>
            <link>https://app.cinevva.com/de/tutorials/service-workers-caching</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/service-workers-caching</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Spiel-Assets mit Service Workern cachen: Strategien, Versionierung und Updates. Damit dein Spiel bei Wiederbesuchen sofort lädt.]]></description>
            <content:encoded><![CDATA[<h1>Service Worker fürs Caching von Spiel-Assets</h1>
<p>Mit Service Workern steuerst du, wie Assets gecacht und ausgeliefert werden. Für Spiele heißt das sofortiges Laden bei Wiederbesuchen und Offline-Unterstützung.</p>
<h2>1) Grundlagen zum Service Worker</h2>
<p>Leg <code>sw.js</code> in deinem Wurzelverzeichnis an:</p>
<pre><code class="language-js">// sw.js
self.addEventListener('install', (event) =&gt; {
  console.log('Service worker installing')
})

self.addEventListener('activate', (event) =&gt; {
  console.log('Service worker activated')
})

self.addEventListener('fetch', (event) =&gt; {
  // Intercept all network requests
})
</code></pre>
<p>Registriere ihn:</p>
<pre><code class="language-js">if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg =&gt; console.log('SW registered'))
    .catch(err =&gt; console.error('SW registration failed:', err))
}
</code></pre>
<h2>2) Cache-first (am besten für Spiel-Assets)</h2>
<p>Aus dem Cache laden und nur holen, was nicht gecacht ist:</p>
<pre><code class="language-js">const CACHE_NAME = 'game-assets-v1'

self.addEventListener('fetch', (event) =&gt; {
  event.respondWith(
    caches.match(event.request).then((cached) =&gt; {
      if (cached) {
        return cached
      }
      
      return fetch(event.request).then((response) =&gt; {
        // Don't cache non-ok responses or non-GET requests
        if (!response.ok || event.request.method !== 'GET') {
          return response
        }
        
        // Clone response for caching
        const clone = response.clone()
        caches.open(CACHE_NAME).then((cache) =&gt; {
          cache.put(event.request, clone)
        })
        
        return response
      })
    })
  )
})
</code></pre>
<h2>3) Kritische Assets vorab cachen</h2>
<p>Das Install-Event eignet sich perfekt fürs Vorab-Caching:</p>
<pre><code class="language-js">const CACHE_NAME = 'game-v1'

const PRECACHE = [
  '/',
  '/index.html',
  '/game.js',
  '/style.css',
  '/assets/sprites.png',
  '/assets/tileset.png',
  '/assets/player.png',
]

self.addEventListener('install', (event) =&gt; {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache =&gt; cache.addAll(PRECACHE))
      .then(() =&gt; self.skipWaiting())
  )
})
</code></pre>
<h2>4) Network-first für dynamische Inhalte</h2>
<p>Nimm Network-first für Dinge, die sich ändern (Bestenlisten, Spielerdaten):</p>
<pre><code class="language-js">self.addEventListener('fetch', (event) =&gt; {
  const url = new URL(event.request.url)
  
  // API calls: network first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request))
    return
  }
  
  // Assets: cache first
  event.respondWith(cacheFirst(event.request))
})

async function networkFirst(request) {
  try {
    const response = await fetch(request)
    const cache = await caches.open('api-cache')
    cache.put(request, response.clone())
    return response
  } catch {
    return caches.match(request)
  }
}

async function cacheFirst(request) {
  const cached = await caches.match(request)
  if (cached) return cached
  
  const response = await fetch(request)
  const cache = await caches.open(CACHE_NAME)
  cache.put(request, response.clone())
  return response
}
</code></pre>
<h2>5) Stale-while-revalidate</h2>
<p>Sofort aus dem Cache antworten und im Hintergrund aktualisieren:</p>
<pre><code class="language-js">async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME)
  const cached = await cache.match(request)
  
  const fetchPromise = fetch(request).then((response) =&gt; {
    if (response.ok) {
      cache.put(request, response.clone())
    }
    return response
  })
  
  return cached || fetchPromise
}
</code></pre>
<h2>6) Cache-Versionierung und Aufräumen</h2>
<p>Alte Caches beim Aktivieren aufräumen:</p>
<pre><code class="language-js">const CACHE_VERSION = 'v2'
const CACHE_NAME = `game-${CACHE_VERSION}`

self.addEventListener('activate', (event) =&gt; {
  event.waitUntil(
    caches.keys().then((keys) =&gt; {
      return Promise.all(
        keys
          .filter(key =&gt; key.startsWith('game-') &amp;&amp; key !== CACHE_NAME)
          .map(key =&gt; {
            console.log('Deleting old cache:', key)
            return caches.delete(key)
          })
      )
    }).then(() =&gt; self.clients.claim())
  )
})
</code></pre>
<h2>7) Getrennte Caches nach Typ</h2>
<p>Caches ordnen macht sie leichter handhabbar:</p>
<pre><code class="language-js">const CACHES = {
  static: 'static-v1',    // HTML, JS, CSS
  images: 'images-v1',    // Sprites, textures
  audio: 'audio-v1',      // Music, SFX
  api: 'api-v1',          // API responses
}

self.addEventListener('fetch', (event) =&gt; {
  const url = new URL(event.request.url)
  
  if (url.pathname.match(/\.(png|jpg|webp)$/)) {
    event.respondWith(cacheFirst(event.request, CACHES.images))
  } else if (url.pathname.match(/\.(mp3|ogg|wav)$/)) {
    event.respondWith(cacheFirst(event.request, CACHES.audio))
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request, CACHES.api))
  } else {
    event.respondWith(cacheFirst(event.request, CACHES.static))
  }
})
</code></pre>
<h2>8) Range-Requests für Audio und Video</h2>
<p>Eines vorab: Die Cache-API speichert keine Teilantworten. Erreicht jemals eine 206-Partial-Content-Antwort cache.put(), wirft sie TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported. Das Muster für gecachte Medien ist deshalb immer dasselbe: Die vollständige Datei als normale 200-Antwort vorab cachen (oder holen) und die 206 dann selbst bauen, indem du die gecachten Bytes schneidest, sobald eine Anfrage mit Range-Header ankommt. Wer das nicht von Hand schreiben will, bekommt genau dieses Schneiden vom range-requests-Plugin von Workbox, kombiniert mit einer CacheFirst-Strategie.</p>
<p>Range-Requests sauber behandeln (nötig fürs Spulen in Audio):</p>
<pre><code class="language-js">self.addEventListener('fetch', (event) =&gt; {
  if (event.request.headers.has('range')) {
    event.respondWith(handleRangeRequest(event.request))
    return
  }
  // ... normal handling
})

async function handleRangeRequest(request) {
  const cache = await caches.open(CACHES.audio)
  // Match the full resource, ignoring the Range header (which would never match a cached full file)
  let cached = await cache.match(request.url)

  if (!cached) {
    // Fetch the FULL file (string URL drops the Range header) and cache it as a 200
    cached = await fetch(request.url)
    if (cached.ok) cache.put(request.url, cached.clone()) // cache.put rejects 206, so only store full 200s
  }

  // Build a 206 Partial Content response from the cached full body
  const rangeHeader = request.headers.get('range') // e.g. &quot;bytes=200-1000&quot;
  const buffer = await cached.arrayBuffer()
  const total = buffer.byteLength
  const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-')
  const start = Number(startStr)
  const end = endStr ? Number(endStr) : total - 1
  const slice = buffer.slice(start, end + 1)

  return new Response(slice, {
    status: 206,
    statusText: 'Partial Content',
    headers: {
      'Content-Type': cached.headers.get('Content-Type') || 'application/octet-stream',
      'Content-Range': `bytes ${start}-${end}/${total}`,
      'Content-Length': String(slice.byteLength),
      'Accept-Ranges': 'bytes',
    },
  })
}
</code></pre>
<h2>9) Update-Hinweis</h2>
<p>Sag Spielern, wenn eine neue Version bereitsteht:</p>
<pre><code class="language-js">// In main app
let refreshing = false

navigator.serviceWorker.addEventListener('controllerchange', () =&gt; {
  if (refreshing) return
  refreshing = true
  
  showUpdateBanner()
})

function showUpdateBanner() {
  const banner = document.createElement('div')
  banner.className = 'update-banner'
  banner.innerHTML = `
    &lt;span&gt;New version available!&lt;/span&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Update&lt;/button&gt;
  `
  document.body.appendChild(banner)
}
</code></pre>
<h2>10) Ein vollständiger Service Worker fürs Spiel</h2>
<pre><code class="language-js">const VERSION = 'v1'
const STATIC_CACHE = `static-${VERSION}`
const ASSET_CACHE = `assets-${VERSION}`

const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/game.js',
  '/style.css',
]

// Install: precache critical resources
self.addEventListener('install', (event) =&gt; {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache =&gt; cache.addAll(PRECACHE_URLS))
      .then(() =&gt; self.skipWaiting())
  )
})

// Activate: clean old caches
self.addEventListener('activate', (event) =&gt; {
  event.waitUntil(
    caches.keys().then(keys =&gt; Promise.all(
      keys
        .filter(key =&gt; !key.endsWith(VERSION))
        .map(key =&gt; caches.delete(key))
    )).then(() =&gt; self.clients.claim())
  )
})

// Fetch: route requests to appropriate strategy
self.addEventListener('fetch', (event) =&gt; {
  const url = new URL(event.request.url)
  
  // Same-origin only
  if (url.origin !== location.origin) {
    return
  }
  
  // API: network first with cache fallback
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request, 'api-cache'))
    return
  }
  
  // Assets: cache first
  if (url.pathname.startsWith('/assets/')) {
    event.respondWith(cacheFirst(event.request, ASSET_CACHE))
    return
  }
  
  // HTML/JS/CSS: stale while revalidate
  event.respondWith(staleWhileRevalidate(event.request, STATIC_CACHE))
})

async function cacheFirst(request, cacheName) {
  const cached = await caches.match(request)
  if (cached) return cached
  
  try {
    const response = await fetch(request)
    if (response.ok) {
      const cache = await caches.open(cacheName)
      cache.put(request, response.clone())
    }
    return response
  } catch {
    return new Response('Offline', { status: 503 })
  }
}

async function networkFirst(request, cacheName) {
  try {
    const response = await fetch(request)
    if (response.ok) {
      const cache = await caches.open(cacheName)
      cache.put(request, response.clone())
    }
    return response
  } catch {
    return caches.match(request) || new Response('Offline', { status: 503 })
  }
}

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName)
  const cached = await cache.match(request)
  
  const fetchPromise = fetch(request).then(response =&gt; {
    if (response.ok) {
      cache.put(request, response.clone())
    }
    return response
  }).catch(() =&gt; cached)
  
  return cached || fetchPromise
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/pwa-offline-games">PWA für Offline-Spiele</a></li>
<li><a href="/de/tutorials/streaming-asset-loading">Streaming von Assets</a></li>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/web-audio-api-games">Web-Audio-API für Spiele</a> — Audio-Assets fürs Offline-Spielen cachen</li>
<li><a href="/de/tutorials/indexeddb-game-saves">Spielstände in IndexedDB</a> — Spielzustand neben gecachten Assets sichern</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">MDN: Service Worker API</a> — vollständige API-Referenz</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Cache">MDN: Cache API</a> — Referenz zur Cache-Schnittstelle</li>
<li><a href="https://web.dev/learn/pwa/service-workers/">web.dev: Service workers</a> — Caching-Strategien und Muster</li>
<li><a href="https://developer.chrome.com/docs/workbox/">Workbox</a> — Googles Bibliothek für Service-Worker-Tooling</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Ein Web-Spiel ausliefern, das schnell lädt (eine praktische Checkliste)]]></title>
            <link>https://app.cinevva.com/de/tutorials/ship-web-game-fast</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/ship-web-game-fast</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Eine pragmatische Schritt-für-Schritt-Checkliste, damit Web-Spiele schnell laden: Budgets, Kompression, Caching, Asset-Formate und Performance-Messungen.]]></description>
            <content:encoded><![CDATA[<h1>Ein Web-Spiel ausliefern, das schnell lädt (eine praktische Checkliste)</h1>
<p>Wenn Spieler nicht <strong>in Sekunden loslegen</strong> können, springen sie ab. Dieses Tutorial ist eine praktische Checkliste, die du auf fast jeden Web-Spiel-Stack anwenden kannst (Canvas/WebGL/WebGPU, Pixi/Three/PlayCanvas, eigene Engine und so weiter).</p>
<h2>1) Erst Budgets setzen (sonst kannst du nicht optimieren)</h2>
<p>Wähle Budgets, die zu deinem Zielgerät passen. Ein solider Standard für ein Sofort-Spielerlebnis:</p>
<ul>
<li><strong>Ausgeliefertes JS+CSS</strong>: unter ~300–500 KB gzip (kleiner ist besser)</li>
<li><strong>Kritische Assets vor der ersten Eingabe</strong>: unter ~2–5 MB komprimiert</li>
<li><strong>Zeit bis zur ersten Eingabe</strong>: unter ~3–5 Sekunden auf einem Mittelklasse-Handy</li>
</ul>
<p>Schreib sie auf und behandle sie wie API-Verträge.</p>
<h2>2) Das Richtige messen</h2>
<p>In den Chrome DevTools:</p>
<ul>
<li>Nutze <strong>Performance</strong>, um eine Sitzung aufzuzeichnen und lange Tasks zu untersuchen.</li>
<li>Nutze <strong>Network</strong>, um zu prüfen:
<ul>
<li>Übertragungsgröße gegen dekodierte Größe</li>
<li>Caching-Header</li>
<li>ob Assets progressiv streamen oder als ein riesiger Klumpen ankommen</li>
</ul>
</li>
</ul>
<p>In Spielbegriffen verfolgst du:</p>
<ul>
<li>die Zeit bis &quot;<strong>kann sich der Spieler bewegen?</strong>&quot;</li>
<li>die Zeit bis zum &quot;<strong>ersten sinnvollen Frame</strong>&quot;</li>
<li>die &quot;<strong>Latenz der ersten Eingabe</strong>&quot; (Eingabe bis Frame)</li>
</ul>
<p>Dein Gespür für die Eingabelatenz entspricht heute einer Standardmetrik: <strong>Interaction to Next Paint (INP)</strong> hat im März 2024 First Input Delay als Core Web Vital abgelöst und misst die volle Zeit von der Eingabe bis zum nächsten Frame über alle Interaktionen hinweg, nicht nur die erste. Ziel ist ein INP unter 200 ms im 75. Perzentil, ein nützlicher externer Maßstab für die Reaktionswerte, die du ohnehin verfolgst.</p>
<h2>3) Komprimiere alles, was geht</h2>
<p>Stell sicher, dass dein Hosting ausliefert:</p>
<ul>
<li><strong>Brotli</strong> (<code>br</code>) für Textassets (JS/CSS/HTML/WASM), wo verfügbar</li>
<li><strong>gzip</strong> als Rückfalloption</li>
</ul>
<p>Wenn Host und CDN es unterstützen, ist <strong>Zstandard</strong> (<code>zstd</code>) inzwischen eine dritte Option für <code>Content-Encoding</code>. Es steckt in Chrome und Edge ab 123, Firefox ab 126 und Safari ab 26.3 (rund 80 % der Nutzer) und erreicht Brotli-nahe Raten bei geringerer Server-CPU-Last, was bei großen WASM- und JSON-Payloads hilft. Browser bewerben <code>Accept-Encoding: zstd</code> nur über HTTPS, halte also Brotli und gzip als Rückfall für alle anderen konfiguriert.</p>
<p>Bei WebAssembly-Builds ist Kompression oft der Unterschied zwischen &quot;sofort&quot; und &quot;lädt nie&quot;.</p>
<h2>4) Lade nicht die ganze Welt im Voraus</h2>
<p>Teile dein Spiel auf in:</p>
<ul>
<li><strong>Boot</strong>: minimaler Loader, Eingabe und erste Szene</li>
<li><strong>Core</strong>: die zentralen Gameplay-Systeme</li>
<li><strong>Optional</strong>: weitere Level, Kosmetik, hochauflösende Texturen, Bonus-Audio und so weiter</li>
</ul>
<p>Lade &quot;Optional&quot; erst, wenn:</p>
<ul>
<li>der Spieler schon spielen kann, und/oder</li>
<li>du weißt, dass er diese Inhalte will (etwa weil er ein Menü oder Level erreicht hat)</li>
</ul>
<h2>5) Die richtigen Asset-Formate wählen</h2>
<p>Für die meisten Web-Spiele:</p>
<ul>
<li><strong>Bilder</strong>: nimm <strong>WebP</strong> (oder <strong>AVIF</strong>, wenn du langsameres Encoding verkraftest) für UI und Hintergründe.</li>
<li><strong>Audio</strong>: nimm <strong>Ogg Vorbis</strong> für den allgemeinen Fall, teste <strong>AAC</strong> für das Apple-Ökosystem.</li>
<li><strong>Video</strong>: nimm <strong>MP4/H.264</strong> für Kompatibilität, <strong>WebM</strong>, wo es unterstützt wird und kleiner ist.</li>
</ul>
<p>Behalte die Dekodierkosten im Blick: &quot;kleinerer Download&quot; kann trotzdem &quot;langsamer zu dekodieren&quot; heißen.</p>
<h2>6) Halte die erste Szene klein und berechenbar</h2>
<p>Vermeide in deiner ersten interaktiven Szene:</p>
<ul>
<li>Stürme von Shader-Kompilierungen</li>
<li>riesige Textur-Uploads</li>
<li>prozedurale Generierung, die den Haupt-Thread blockiert</li>
<li>synchrones JSON-Parsen von Megabyte-Klumpen</li>
</ul>
<p>Speziell bei Texturen liefere <strong>KTX2 mit Basis-Universal</strong>-Superkompression statt PNG oder JPEG aus. Sie bleibt nach dem Upload GPU-komprimiert (beim Laden transkodiert zu BC7 auf dem Desktop oder ASTC auf Mobilgeräten), was das VRAM um das 4- bis 8-Fache senkt und Uploads weit günstiger macht, als ein volles PNG zu dekodieren und rohe Pixel auf die GPU zu schieben.</p>
<p>Wenn du schwere Arbeit erledigen musst, dann:</p>
<ul>
<li>verteilt über mehrere Frames, oder</li>
<li>in einem Worker (wo möglich)</li>
</ul>
<h2>7) Aggressiv cachen (aber richtig)</h2>
<p>Nutze langlebiges Caching für inhaltsadressierte Assets:</p>
<ul>
<li><code>Cache-Control: public, max-age=31536000, immutable</code></li>
</ul>
<p>Für HTML-Einstiegspunkte hältst du das Caching kurz, damit du Updates sicher ausrollen kannst.</p>
<h2>8) Mach Fehlschläge schnell und lesbar</h2>
<p>Wenn etwas schiefgeht, zeig:</p>
<ul>
<li>eine klare Fehlermeldung</li>
<li>einen Button zum erneuten Versuch</li>
<li>einen Link zum Melden des Problems</li>
</ul>
<p>Stille Fehlschläge zerstören Vertrauen.</p>
<h2>9) Wenn du Wasm-Threads nutzt, versteh COOP/COEP</h2>
<p>Hängt dein Build von <code>SharedArrayBuffer</code> ab (bei manchen Wasm-Threading-Setups üblich), brauchst du sehr wahrscheinlich Header für <strong>Cross-Origin-Isolation</strong>:</p>
<ul>
<li><code>Cross-Origin-Opener-Policy: same-origin</code></li>
<li><code>Cross-Origin-Embedder-Policy: require-corp</code></li>
</ul>
<p>Beim COEP-Header hast du zwei Möglichkeiten. <code>require-corp</code> ist die strenge Variante, verlangt aber, dass jedes Cross-Origin-Asset (CDN-Texturen, Drittanbieter-Skripte) einen <code>Cross-Origin-Resource-Policy</code>-Header sendet, sonst wird es blockiert. <code>Cross-Origin-Embedder-Policy: credentialless</code> schaltet <code>SharedArrayBuffer</code> und Cross-Origin-Isolation ebenfalls frei, lädt Cross-Origin-Ressourcen aber ohne Zugangsdaten, statt ihre Zustimmung per CORP zu verlangen. Wenn du Assets von einem CDN ziehst, das du nicht kontrollierst, ist <code>credentialless</code> meist der weniger schmerzhafte Weg.</p>
<p>Teste früh auf deinem echten Host, denn Header und Drittanbieter-Embeds können vieles kaputt machen.</p>
<h2>10) Mach es ohne SDK spielbar</h2>
<p>Wenn du auf Cinevvas Creator-Workflow zielst, streb an:</p>
<ul>
<li>eine URL, die zuverlässig startet</li>
<li>keine Pflichtanmeldung, um eine Demo zu starten</li>
<li>vorhersehbare Steuerung</li>
</ul>
<p>Dann bist du bereit für den Vertrieb, ohne zusätzliche SDKs einzubinden.</p>
<p>Verwandt:</p>
<ul>
<li><a href="/de/creators">Für Spiele-Creator</a></li>
<li><a href="/de/ai-content">Richtlinie zu KI-generierten Inhalten</a></li>
<li><a href="/de/tutorials/streaming-asset-loading">Streaming von Assets</a> — Muster für progressives Laden bei großen Spielen</li>
<li><a href="/de/tutorials/service-workers-caching">Service Worker fürs Spiel-Caching</a> — Assets für sofortige Wiederbesuche cachen</li>
<li><a href="/de/guides/web-game-engines-comparison">Web-Game-Engines im Vergleich</a> — Build-Größen und Ladezeiten der Engines</li>
<li><a href="/de/guides/itch-io-launch-guide">Wie du dein Spiel auf itch.io veröffentlichst</a> — Browser-Spiele für itch.io-Embeds optimieren</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Assets für Web-Spiele streamen]]></title>
            <link>https://app.cinevva.com/de/tutorials/streaming-asset-loading</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/streaming-asset-loading</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Spiel-Assets schrittweise laden: Priorisierung, Streaming, Caching und Fortschrittsanzeige. Damit Spieler beim Laden dranbleiben.]]></description>
            <content:encoded><![CDATA[<h1>Assets für Web-Spiele streamen</h1>
<p>Spieler warten nicht auf einen 100-MB-Download. Lade Assets schrittweise, damit sie spielen können, während der Rest im Hintergrund lädt.</p>
<h2>1) Die Ladestrategie</h2>
<p>Teile Assets in Stufen:</p>
<ol>
<li><strong>Kritisch</strong> — nötig, um den ersten Bildschirm zu zeigen (&lt; 1 MB)</li>
<li><strong>Gameplay</strong> — nötig, um zu spielen (&lt; 10 MB)</li>
<li><strong>Verbesserung</strong> — nett zu haben (im Hintergrund laden)</li>
</ol>
<h2>2) Ein einfacher Asset-Loader</h2>
<pre><code class="language-js">class AssetLoader {
  constructor() {
    this.cache = new Map()
    this.loading = new Map()
  }
  
  async loadImage(url) {
    if (this.cache.has(url)) return this.cache.get(url)
    if (this.loading.has(url)) return this.loading.get(url)
    
    const promise = new Promise((resolve, reject) =&gt; {
      const img = new Image()
      img.onload = () =&gt; {
        this.cache.set(url, img)
        this.loading.delete(url)
        resolve(img)
      }
      img.onerror = reject
      img.src = url
    })
    
    this.loading.set(url, promise)
    return promise
  }
  
  async loadJSON(url) {
    if (this.cache.has(url)) return this.cache.get(url)
    
    const response = await fetch(url)
    const data = await response.json()
    this.cache.set(url, data)
    return data
  }
  
  async loadAudio(url, audioCtx) {
    if (this.cache.has(url)) return this.cache.get(url)
    
    const response = await fetch(url)
    const buffer = await response.arrayBuffer()
    const audioBuffer = await audioCtx.decodeAudioData(buffer)
    this.cache.set(url, audioBuffer)
    return audioBuffer
  }
}
</code></pre>
<h2>3) Laden mit Fortschritt</h2>
<pre><code class="language-js">async function loadWithProgress(url, onProgress) {
  const response = await fetch(url)
  const contentLength = response.headers.get('Content-Length')
  const total = parseInt(contentLength, 10)
  
  const reader = response.body.getReader()
  const chunks = []
  let received = 0
  
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    
    chunks.push(value)
    received += value.length
    onProgress(received / total)
  }
  
  const blob = new Blob(chunks)
  return blob
}
</code></pre>
<p>Achtung: Wenn der Server die Antwort komprimiert (gzip oder Brotli, was die meisten CDNs standardmäßig tun), ist <code>Content-Length</code> die komprimierte Größe, während die Chunks, die du liest, bereits dekomprimiert sind. Dadurch schießt <code>received / total</code> über 100 % hinaus. Für einen genauen Fortschrittsbalken auf Byte-Ebene bei komprimierten Antworten lieferst du das Asset entweder unkomprimiert aus, sendest einen eigenen Header mit der unkomprimierten Größe oder weichst auf das <code>onprogress</code>-Event von <code>XMLHttpRequest</code> aus, das den echten Übertragungsfortschritt meldet.</p>
<h2>4) Stapelweise laden mit Gesamtfortschritt</h2>
<pre><code class="language-js">async function loadAssets(manifest, onProgress) {
  const total = manifest.length
  let completed = 0
  const results = {}
  
  const promises = manifest.map(async (item) =&gt; {
    const asset = await loadAsset(item.url, item.type)
    results[item.name] = asset
    completed++
    onProgress(completed / total, item.name)
  })
  
  await Promise.all(promises)
  return results
}

// Usage
const manifest = [
  { name: 'player', url: 'player.png', type: 'image' },
  { name: 'level1', url: 'level1.json', type: 'json' },
  { name: 'music', url: 'music.mp3', type: 'audio' },
]

const assets = await loadAssets(manifest, (progress, name) =&gt; {
  console.log(`Loading: ${Math.round(progress * 100)}% (${name})`)
})
</code></pre>
<h2>5) Loader mit Prioritätswarteschlange</h2>
<pre><code class="language-js">class PriorityLoader {
  constructor(concurrency = 4) {
    this.queue = []
    this.active = 0
    this.concurrency = concurrency
  }
  
  add(url, priority = 0) {
    return new Promise((resolve, reject) =&gt; {
      this.queue.push({ url, priority, resolve, reject })
      this.queue.sort((a, b) =&gt; b.priority - a.priority)
      this.process()
    })
  }
  
  async process() {
    if (this.active &gt;= this.concurrency || this.queue.length === 0) return
    
    this.active++
    const { url, resolve, reject } = this.queue.shift()
    
    try {
      const response = await fetch(url)
      const blob = await response.blob()
      resolve(blob)
    } catch (err) {
      reject(err)
    }
    
    this.active--
    this.process()
  }
}

// Usage
const loader = new PriorityLoader()
loader.add('critical.png', 10)  // Load first
loader.add('optional.png', 1)   // Load later
</code></pre>
<h2>6) Level nachladen</h2>
<pre><code class="language-js">class LevelManager {
  constructor(loader) {
    this.loader = loader
    this.levels = new Map()
  }
  
  async preload(levelId) {
    if (this.levels.has(levelId)) return
    
    const manifest = await this.loader.loadJSON(`levels/${levelId}/manifest.json`)
    const assets = await this.loadLevelAssets(manifest)
    this.levels.set(levelId, { manifest, assets })
  }
  
  async loadLevelAssets(manifest) {
    // Load only what's needed for this level
    const assets = {}
    
    for (const texture of manifest.textures) {
      assets[texture.name] = await this.loader.loadImage(texture.url)
    }
    
    return assets
  }
  
  unload(levelId) {
    this.levels.delete(levelId)
    // Assets can be garbage collected
  }
}
</code></pre>
<h2>7) Große Dateien streamen</h2>
<p>Bei großen Dateien (3D-Modelle, Audio) streamst du und verarbeitest schrittweise:</p>
<pre><code class="language-js">async function streamLargeFile(url, onChunk) {
  const response = await fetch(url)
  const reader = response.body.getReader()
  
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    onChunk(value)
  }
}

// For audio: use Media Source Extensions
// For 3D: process mesh data as it arrives
</code></pre>
<h2>8) Geladene Assets cachen</h2>
<p>Kombiniere das mit IndexedDB für dauerhaftes Caching:</p>
<pre><code class="language-js">class CachedLoader {
  constructor() {
    this.memCache = new Map()
    this.dbName = 'AssetCache'
  }
  
  async loadImage(url) {
    // Check memory
    if (this.memCache.has(url)) return this.memCache.get(url)
    
    // Check IndexedDB
    const cached = await this.getFromDB(url)
    if (cached) {
      const img = await this.blobToImage(cached)
      this.memCache.set(url, img)
      return img
    }
    
    // Fetch and cache
    const response = await fetch(url)
    const blob = await response.blob()
    await this.saveToDB(url, blob)
    
    const img = await this.blobToImage(blob)
    this.memCache.set(url, img)
    return img
  }
  
  blobToImage(blob) {
    return new Promise((resolve) =&gt; {
      const img = new Image()
      img.onload = () =&gt; {
        URL.revokeObjectURL(img.src)
        resolve(img)
      }
      img.src = URL.createObjectURL(blob)
    })
  }
  
  // IndexedDB methods...
}
</code></pre>
<h2>9) Muster für einen Ladebildschirm</h2>
<pre><code class="language-js">class LoadingScreen {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.progress = 0
    this.message = 'Loading...'
  }
  
  update(progress, message) {
    this.progress = progress
    this.message = message || this.message
    this.render()
  }
  
  render() {
    const { ctx, canvas } = this
    ctx.fillStyle = '#1a1a2e'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    
    // Progress bar
    const barWidth = canvas.width * 0.6
    const barHeight = 20
    const x = (canvas.width - barWidth) / 2
    const y = canvas.height / 2
    
    ctx.fillStyle = '#333'
    ctx.fillRect(x, y, barWidth, barHeight)
    
    ctx.fillStyle = '#4ade80'
    ctx.fillRect(x, y, barWidth * this.progress, barHeight)
    
    // Text
    ctx.fillStyle = '#fff'
    ctx.font = '16px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText(this.message, canvas.width / 2, y - 20)
    ctx.fillText(`${Math.round(this.progress * 100)}%`, canvas.width / 2, y + 50)
  }
}
</code></pre>
<h2>10) Best Practices</h2>
<ul>
<li><strong>Zeig sofort etwas</strong> — und sei es ein statisches Bild</li>
<li><strong>Lade zuerst, was sichtbar ist</strong> — Texturen für die aktuelle Ansicht</li>
<li><strong>Nutz Platzhalter</strong> — niedrig aufgelöste Bilder, die später hochgestuft werden</li>
<li><strong>Lade das nächste Level vor</strong> — während der Spieler noch spielt</li>
<li><strong>Behandle Fehler sauber</strong> — Wiederholungslogik, Rückfallwege</li>
</ul>
<pre><code class="language-js">async function loadWithRetry(url, maxRetries = 3) {
  for (let i = 0; i &lt; maxRetries; i++) {
    try {
      return await fetch(url)
    } catch (err) {
      if (i === maxRetries - 1) throw err
      await new Promise(r =&gt; setTimeout(r, 1000 * (i + 1)))
    }
  }
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/service-workers-caching">Service Worker fürs Spiel-Caching</a></li>
<li><a href="/de/tutorials/indexeddb-game-saves">Spielstände in IndexedDB</a></li>
<li><a href="/de/tutorials/web-workers-game-logic">Web Workers für Spiellogik</a> — Assets abseits des Haupt-Threads dekodieren und verarbeiten</li>
<li><a href="/de/guides/game-assets-guide">Wo du kostenlose Game-Assets findest</a> — Quellen für 3D-Modelle, Texturen und Audio zum Streamen</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">MDN: Fetch API</a> — Antworten streamen und ReadableStream</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream">MDN: ReadableStream</a> — gestreamte Datenblöcke verarbeiten</li>
<li><a href="https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html">glTF 2.0 specification</a> — das Standardformat zum Streamen von 3D-Assets</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Textdarstellung in Web-Spielen]]></title>
            <link>https://app.cinevva.com/de/tutorials/text-rendering-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/text-rendering-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Text in Spielen rendern: Canvas-Text, Bitmap-Fonts, SDF-Fonts und Performance. Von einfachen Beschriftungen bis zum Dialogsystem.]]></description>
            <content:encoded><![CDATA[<h1>Textdarstellung in Web-Spielen</h1>
<p>Text ist in Spielen überall: Punktestände, Dialoge, Menüs, Schadenszahlen. Dieses Tutorial zeigt verschiedene Ansätze und wann welcher passt.</p>
<h2>1) Grundlagen von Canvas-Text</h2>
<p>Der einfachste Weg:</p>
<pre><code class="language-js">ctx.font = '24px Arial'
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('Score: 1000', canvas.width / 2, 50)
</code></pre>
<p>Brauchst du für Überschriften und HUD-Beschriftungen engere oder weitere Abstände, unterstützt der Canvas-Kontext inzwischen <code>letterSpacing</code> und <code>wordSpacing</code> direkt. Beide nehmen CSS-Längenangaben und haben 2025 browserübergreifend Baseline erreicht, du musst Glyphen also nicht mehr einzeln zeichnen, um Kerning nachzubauen:</p>
<pre><code class="language-js">ctx.font = '24px sans-serif'
ctx.letterSpacing = '2px'
ctx.wordSpacing = '6px'
ctx.fillText('GAME OVER', 100, 100)
</code></pre>
<p>Setz sie danach auf <code>'0px'</code> zurück, denn sie bleiben am Kontext hängen wie <code>font</code> und <code>fillStyle</code>.</p>
<h2>2) Text mit Umriss</h2>
<pre><code class="language-js">function drawTextWithOutline(ctx, text, x, y, fillColor, outlineColor, outlineWidth = 2) {
  ctx.font = '24px Arial'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  
  ctx.strokeStyle = outlineColor
  ctx.lineWidth = outlineWidth
  ctx.strokeText(text, x, y)
  
  ctx.fillStyle = fillColor
  ctx.fillText(text, x, y)
}

drawTextWithOutline(ctx, 'GAME OVER', 400, 300, '#fff', '#000', 3)
</code></pre>
<h2>3) Text mit Schatten</h2>
<pre><code class="language-js">function drawTextWithShadow(ctx, text, x, y, color, shadowOffset = 2) {
  ctx.font = '32px sans-serif'
  ctx.textAlign = 'left'
  ctx.textBaseline = 'top'
  
  // Shadow
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillText(text, x + shadowOffset, y + shadowOffset)
  
  // Main text
  ctx.fillStyle = color
  ctx.fillText(text, x, y)
}
</code></pre>
<h2>4) Bitmap-Fonts rendern</h2>
<p>Für Pixel-Art-Spiele oder ein plattformübergreifend gleiches Aussehen:</p>
<pre><code class="language-js">class BitmapFont {
  constructor(image, charWidth, charHeight, chars) {
    this.image = image
    this.charWidth = charWidth
    this.charHeight = charHeight
    this.chars = chars
  }
  
  draw(ctx, text, x, y, scale = 1) {
    const w = this.charWidth * scale
    const h = this.charHeight * scale
    
    for (let i = 0; i &lt; text.length; i++) {
      const charIndex = this.chars.indexOf(text[i].toUpperCase())
      if (charIndex === -1) continue
      
      const sx = (charIndex % 16) * this.charWidth
      const sy = Math.floor(charIndex / 16) * this.charHeight
      
      ctx.drawImage(
        this.image,
        sx, sy, this.charWidth, this.charHeight,
        Math.round(x + i * w), Math.round(y), w, h
      )
    }
  }
  
  measureText(text, scale = 1) {
    return text.length * this.charWidth * scale
  }
}

// Usage
const font = new BitmapFont(fontImage, 8, 8, 
  'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?-+:; '
)
font.draw(ctx, 'SCORE: 1000', 10, 10, 2)
</code></pre>
<h2>5) Vorgerenderter Text für Performance</h2>
<p>Bei statischem Text einmal rendern und wiederverwenden:</p>
<pre><code class="language-js">function createTextImage(text, font, color) {
  const tempCanvas = document.createElement('canvas')
  const tempCtx = tempCanvas.getContext('2d')
  
  tempCtx.font = font
  const metrics = tempCtx.measureText(text)
  
  tempCanvas.width = Math.ceil(metrics.width)
  tempCanvas.height = parseInt(font) * 1.5
  
  tempCtx.font = font
  tempCtx.fillStyle = color
  tempCtx.textBaseline = 'top'
  tempCtx.fillText(text, 0, 0)
  
  return tempCanvas
}

// Create once
const titleImage = createTextImage('MY GAME', '48px Impact', '#fff')

// Draw many times (fast)
ctx.drawImage(titleImage, x, y)
</code></pre>
<h2>6) Schreibmaschineneffekt</h2>
<pre><code class="language-js">class TypewriterText {
  constructor(text, x, y, charsPerSecond = 20) {
    this.fullText = text
    this.x = x
    this.y = y
    this.speed = 1 / charsPerSecond
    this.timer = 0
    this.charIndex = 0
  }
  
  update(dt) {
    this.timer += dt
    while (this.timer &gt;= this.speed &amp;&amp; this.charIndex &lt; this.fullText.length) {
      this.charIndex++
      this.timer -= this.speed
    }
  }
  
  draw(ctx) {
    ctx.font = '18px monospace'
    ctx.fillStyle = '#fff'
    ctx.fillText(this.fullText.slice(0, this.charIndex), this.x, this.y)
  }
  
  isComplete() {
    return this.charIndex &gt;= this.fullText.length
  }
  
  skip() {
    this.charIndex = this.fullText.length
  }
}
</code></pre>
<h2>7) Text mit Zeilenumbruch</h2>
<pre><code class="language-js">function wrapText(ctx, text, maxWidth) {
  const words = text.split(' ')
  const lines = []
  let currentLine = ''
  
  for (const word of words) {
    const testLine = currentLine ? currentLine + ' ' + word : word
    const metrics = ctx.measureText(testLine)
    
    if (metrics.width &gt; maxWidth &amp;&amp; currentLine) {
      lines.push(currentLine)
      currentLine = word
    } else {
      currentLine = testLine
    }
  }
  
  if (currentLine) lines.push(currentLine)
  return lines
}

function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
  const lines = wrapText(ctx, text, maxWidth)
  for (let i = 0; i &lt; lines.length; i++) {
    ctx.fillText(lines[i], x, y + i * lineHeight)
  }
}
</code></pre>
<h2>8) Ein Dialogbox-System</h2>
<pre><code class="language-js">class DialogueBox {
  constructor(x, y, width, height) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
    this.padding = 16
    this.lineHeight = 24
    this.text = ''
    this.displayedChars = 0
    this.charsPerSecond = 30
    this.timer = 0
  }
  
  show(text) {
    this.text = text
    this.displayedChars = 0
    this.timer = 0
  }
  
  update(dt) {
    if (this.displayedChars &lt; this.text.length) {
      this.timer += dt
      const charsToAdd = Math.floor(this.timer * this.charsPerSecond)
      this.displayedChars = Math.min(this.displayedChars + charsToAdd, this.text.length)
      this.timer = this.timer % (1 / this.charsPerSecond)
    }
  }
  
  skip() {
    this.displayedChars = this.text.length
  }
  
  isComplete() {
    return this.displayedChars &gt;= this.text.length
  }
  
  draw(ctx) {
    // Box background
    ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'
    ctx.fillRect(this.x, this.y, this.width, this.height)
    
    // Border
    ctx.strokeStyle = '#fff'
    ctx.lineWidth = 2
    ctx.strokeRect(this.x, this.y, this.width, this.height)
    
    // Text
    ctx.font = '18px sans-serif'
    ctx.fillStyle = '#fff'
    ctx.textBaseline = 'top'
    
    const displayText = this.text.slice(0, this.displayedChars)
    const maxWidth = this.width - this.padding * 2
    const lines = wrapText(ctx, displayText, maxWidth)
    
    for (let i = 0; i &lt; lines.length; i++) {
      ctx.fillText(
        lines[i],
        this.x + this.padding,
        this.y + this.padding + i * this.lineHeight
      )
    }
    
    // Continue indicator
    if (this.isComplete()) {
      ctx.fillText('▼', this.x + this.width - 24, this.y + this.height - 24)
    }
  }
}
</code></pre>
<h2>9) Aufsteigende Schadenszahlen</h2>
<pre><code class="language-js">class FloatingText {
  constructor(text, x, y, color = '#fff') {
    this.text = text
    this.x = x
    this.y = y
    this.color = color
    this.vy = -60
    this.lifetime = 1
    this.age = 0
  }
  
  update(dt) {
    this.y += this.vy * dt
    this.vy *= 0.95
    this.age += dt
  }
  
  draw(ctx) {
    const alpha = 1 - (this.age / this.lifetime)
    ctx.font = 'bold 20px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillStyle = this.color
    ctx.globalAlpha = alpha
    ctx.fillText(this.text, Math.round(this.x), Math.round(this.y))
    ctx.globalAlpha = 1
  }
  
  isExpired() {
    return this.age &gt;= this.lifetime
  }
}

// Manager
const floatingTexts = []

function spawnDamageNumber(damage, x, y) {
  const color = damage &gt; 50 ? '#ff4444' : '#ffff44'
  floatingTexts.push(new FloatingText(damage.toString(), x, y, color))
}

function updateFloatingTexts(dt) {
  for (let i = floatingTexts.length - 1; i &gt;= 0; i--) {
    floatingTexts[i].update(dt)
    if (floatingTexts[i].isExpired()) {
      floatingTexts.splice(i, 1)
    }
  }
}
</code></pre>
<h2>10) Text in WebGL (SDF-Fonts)</h2>
<p>Für WebGL-Spiele skalieren Signed-Distance-Field-Fonts sauber:</p>
<pre><code class="language-js">// SDF shader fragment
const sdfFragmentShader = `
  precision mediump float;
  
  uniform sampler2D u_texture;
  uniform vec4 u_color;
  varying vec2 v_texCoord;
  
  void main() {
    float distance = texture2D(u_texture, v_texCoord).a;
    float alpha = smoothstep(0.4, 0.6, distance);
    gl_FragColor = vec4(u_color.rgb, u_color.a * alpha);
  }
`

// Libraries like msdf-bmfont-xml can generate SDF font atlases
</code></pre>
<p>Speziell für three.js-Szenen ist <code>troika-three-text</code> eine gute Option. Statt einen Atlas vorzubacken, parst es <code>.ttf</code>-, <code>.otf</code>- und <code>.woff</code>-Dateien und erzeugt die SDF-Glyphen im laufenden Betrieb in einem Web Worker, und es kümmert sich um Kerning, Ligaturen und rechtsläufige Schriften.</p>
<p>Für die meisten 2D-Canvas-Spiele funktionieren Bitmap-Fonts oder vorgerenderter Canvas-Text gut.</p>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a></li>
<li><a href="/de/tutorials/pixel-art-rendering">Pixel-Art-Rendering</a></li>
<li><a href="/de/tutorials/webgl-fundamentals-for-games">WebGL-Grundlagen</a></li>
<li><a href="/de/tutorials/responsive-game-canvas">Responsives Spiel-Canvas</a> — Text mit Canvas-Auflösung und DPI skalieren</li>
<li><a href="/de/guides/game-assets-guide">Wo du kostenlose Game-Assets findest</a> — Quellen für Schriften und UI-Assets</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Tone.js für Spiel-Audio]]></title>
            <link>https://app.cinevva.com/de/tutorials/tone-js-game-audio</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/tone-js-game-audio</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Überzeugende Spielklänge mit Tone.js bauen: Soundeffekte per Synthese, dynamische Musik und reaktives Audio, das aufs Gameplay reagiert.]]></description>
            <content:encoded><![CDATA[<h1>Tone.js für Spiel-Audio</h1>
<p>Tone.js sitzt auf der Web-Audio-API und macht synthetisiertes Audio praktikabel. Statt Audiodateien zu laden, erzeugst du Klänge programmatisch. Das heißt null Ladezeit, unendliche Variationen und Klänge, die in Echtzeit aufs Gameplay reagieren.</p>
<p><strong>Probier es aus (klick die Buttons, um synthetisierte Spielklänge zu hören):</strong></p>
<ToneDemo />
<h2>Warum Tone.js statt Audiodateien</h2>
<p>Für Musik und komplexe Klänge funktionieren Audiodateien gut. Bei Spiel-Soundeffekten hat Synthese aber echte Vorteile. Du kannst Tonhöhe und Timing bei jedem Abspielen variieren, damit Klänge nicht repetitiv wirken. Du kannst Klänge spontan aus dem Spielzustand erzeugen. Und du sparst dir die Netzwerkanfragen komplett.</p>
<p>Der Nachteil ist, dass synthetisiertes Audio mehr CPU kostet als das Abspielen einer Datei. Für einfache Spiele ist das kein Problem. Für komplexe Spiele mischst du vielleicht beide Ansätze.</p>
<h2>Einrichtung</h2>
<p>Lade Tone.js von einem CDN oder über npm:</p>
<pre><code class="language-html">&lt;script src=&quot;https://unpkg.com/tone@15/build/Tone.js&quot;&gt;&lt;/script&gt;
</code></pre>
<p>Oder mit npm:</p>
<pre><code class="language-bash">npm install tone
</code></pre>
<pre><code class="language-js">import * as Tone from 'tone'
</code></pre>
<h2>Das Autoplay-Problem</h2>
<p>Browser blockieren Audio bis zur Nutzerinteraktion. Tone.js kümmert sich darum, du musst aber <code>Tone.start()</code> bei einer Nutzergeste aufrufen:</p>
<pre><code class="language-js">document.addEventListener('click', async () =&gt; {
  await Tone.start()
  console.log('Audio ready')
}, { once: true })
</code></pre>
<p>Ohne das bleibt dein AudioContext suspendiert und nichts spielt.</p>
<h2>Dein erster Synth</h2>
<p>Ein Synth erzeugt Klang. Der einfachste ist <code>Tone.Synth</code>:</p>
<pre><code class="language-js">const synth = new Tone.Synth().toDestination()

// Play a note
synth.triggerAttackRelease('C4', '8n')
</code></pre>
<p><code>triggerAttackRelease</code> spielt eine Note für eine Dauer. <code>'C4'</code> ist das mittlere C. <code>'8n'</code> ist eine Achtelnote im aktuellen Tempo.</p>
<p>Der Synth hat einen Oszillator (die Klangquelle) und eine Hüllkurve (wie der Klang ein- und ausblendet). Beides kannst du anpassen:</p>
<pre><code class="language-js">const synth = new Tone.Synth({
  oscillator: { type: 'triangle' },
  envelope: {
    attack: 0.02,
    decay: 0.2,
    sustain: 0.3,
    release: 0.4,
  },
}).toDestination()
</code></pre>
<p>Oszillatortypen sind unter anderem <code>sine</code> (rein, weich), <code>triangle</code> (sanft), <code>sawtooth</code> (schnarrend) und <code>square</code> (hohl, retro).</p>
<h2>Ein System für Soundeffekte bauen</h2>
<p>Für ein Spiel willst du mehrere Synths für verschiedene Klangarten. Dieses Muster funktioniert gut:</p>
<pre><code class="language-js">let isInitialized = false
let softSynth = null
let subSynth = null
let noiseSynth = null
let sfxGain = null

function initSFX() {
  if (isInitialized) return
  
  // Master volume control
  sfxGain = new Tone.Gain(0.7).toDestination()
  
  // Soft synth for melodic sounds
  softSynth = new Tone.PolySynth(Tone.Synth, {
    maxPolyphony: 6,
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.2,
      sustain: 0.3,
      release: 0.4,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // Sub bass for deep impacts
  subSynth = new Tone.Synth({
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.3,
      sustain: 0.1,
      release: 0.3,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // Noise for texture
  noiseSynth = new Tone.NoiseSynth({
    noise: { type: 'pink' },
    envelope: {
      attack: 0.02,
      decay: 0.15,
      sustain: 0,
      release: 0.15,
    },
    volume: -20,
  }).connect(sfxGain)
  
  isInitialized = true
}
</code></pre>
<p><code>PolySynth</code> lässt dich mehrere Noten gleichzeitig spielen. Der normale <code>Synth</code> ist monophon.</p>
<h2>Beispiele für Spielklänge</h2>
<p>Hier ein paar gängige Spielklänge und wie du sie erzeugst:</p>
<p><strong>Münze einsammeln</strong> — aufsteigende Noten fühlen sich belohnend an:</p>
<pre><code class="language-js">function coinCollect() {
  const now = Tone.now()
  softSynth.triggerAttackRelease('C4', '8n', now, 0.3)
  softSynth.triggerAttackRelease('E4', '8n', now + 0.1, 0.35)
  softSynth.triggerAttackRelease('G4', '4n', now + 0.2, 0.4)
}
</code></pre>
<p><strong>Sprung</strong> — schneller Aufwärtsschwung:</p>
<pre><code class="language-js">function jump() {
  const synth = new Tone.Synth({
    oscillator: { type: 'square' },
    envelope: { attack: 0.01, decay: 0.1, sustain: 0, release: 0.1 },
  }).toDestination()
  
  const now = Tone.now()
  synth.frequency.setValueAtTime(150, now)
  synth.frequency.exponentialRampToValueAtTime(400, now + 0.1)
  synth.triggerAttackRelease('C4', '8n', now, 0.2)
}
</code></pre>
<p><strong>Treffer und Schaden</strong> — absteigend mit Rauschen:</p>
<pre><code class="language-js">function hit() {
  const now = Tone.now()
  subSynth.triggerAttackRelease('E2', '4n', now, 0.5)
  noiseSynth.triggerAttackRelease('4n', now, 0.25)
}
</code></pre>
<p><strong>Tod</strong> — traurige absteigende Noten:</p>
<pre><code class="language-js">function death() {
  const now = Tone.now()
  softSynth.triggerAttackRelease('E4', '4n', now, 0.4)
  softSynth.triggerAttackRelease('C4', '4n', now + 0.2, 0.35)
  softSynth.triggerAttackRelease('A3', '4n', now + 0.4, 0.3)
}
</code></pre>
<h2>Effekte ergänzen</h2>
<p>Effekte machen Klänge reicher. Häng sie zwischen Synth und Ausgang:</p>
<pre><code class="language-js">const filter = new Tone.Filter({
  frequency: 3000,
  type: 'lowpass',
  rolloff: -12,
})

const reverb = new Tone.Reverb({
  decay: 1.2,
  wet: 0.25,
})

const gain = new Tone.Gain(0.7)

// Chain: synth -&gt; filter -&gt; reverb -&gt; gain -&gt; destination
filter.connect(reverb)
reverb.connect(gain)
gain.toDestination()

const synth = new Tone.Synth().connect(filter)
</code></pre>
<p>Der Tiefpassfilter schneidet harte hohe Frequenzen. Der Hall gibt Raum. Gain regelt die Lautstärke.</p>
<h2>Ratenbegrenzung</h2>
<p>Löst dein Spiel viele Klänge in schneller Folge aus, können sie sich überlagern und zu Matsch werden. Ergänze eine Ratenbegrenzung:</p>
<pre><code class="language-js">const MIN_INTERVAL_MS = 35
let lastNoteTime = 0

function canPlayNote() {
  const now = performance.now()
  if (now - lastNoteTime &lt; MIN_INTERVAL_MS) {
    return false
  }
  lastNoteTime = now
  return true
}

function playSound() {
  if (!canPlayNote()) return
  synth.triggerAttackRelease('C4', '8n')
}
</code></pre>
<h2>Dynamische Hintergrundmusik</h2>
<p>Für Musik, die aufs Gameplay reagiert, nimm ein taktbasiertes System:</p>
<pre><code class="language-js">const BPM = 140
const BEAT_MS = (60 / BPM) * 1000

let bassSynth = null
let kickDrum = null
let beatInterval = null
let beatCount = 0
let energy = 0.3

const PROGRESSION = [
  { root: 'E2', chord: ['E3', 'G3', 'B3'] },
  { root: 'C2', chord: ['C3', 'E3', 'G3'] },
  { root: 'D2', chord: ['D3', 'F#3', 'A3'] },
  { root: 'A1', chord: ['A2', 'C3', 'E3'] },
]

function playBeat() {
  const beatInMeasure = beatCount % 8
  const chordIndex = Math.floor(beatCount / 8) % PROGRESSION.length
  const chord = PROGRESSION[chordIndex]
  const now = Tone.now()
  
  // Bass on beat 1
  if (beatInMeasure === 0) {
    bassSynth.triggerAttackRelease(chord.root, '4n', now, 0.6)
  }
  
  // Kick drum - only when energy is high
  if (beatInMeasure === 0 &amp;&amp; energy &gt; 0.3) {
    kickDrum.triggerAttackRelease('C1', '8n', now, 0.4)
  }
  
  beatCount++
}

function startMusic() {
  beatInterval = setInterval(playBeat, BEAT_MS)
}

function stopMusic() {
  clearInterval(beatInterval)
}

// Game events affect energy
function onEnemyKill() {
  energy = Math.min(1, energy + 0.1)
}
</code></pre>
<p>Die Intensität der Musik ändert sich je nachdem, was im Spiel passiert.</p>
<h2>Schlagzeug mit MembraneSynth und NoiseSynth</h2>
<p>Für Perkussion nimmst du spezialisierte Synths:</p>
<pre><code class="language-js">// Kick drum
const kick = new Tone.MembraneSynth({
  pitchDecay: 0.05,
  octaves: 4,
  envelope: {
    attack: 0.001,
    decay: 0.2,
    sustain: 0,
    release: 0.1,
  },
  volume: -14,
}).toDestination()

kick.triggerAttackRelease('C1', '8n')

// Snare
const snare = new Tone.NoiseSynth({
  noise: { type: 'white' },
  envelope: {
    attack: 0.002,
    decay: 0.15,
    sustain: 0,
    release: 0.1,
  },
  volume: -12,
}).toDestination()

snare.triggerAttackRelease('8n')

// Hi-hat
const hihat = new Tone.NoiseSynth({
  noise: { type: 'white' },
  envelope: {
    attack: 0.001,
    decay: 0.04,
    sustain: 0,
    release: 0.03,
  },
  volume: -22,
}).toDestination()

hihat.triggerAttackRelease('32n')
</code></pre>
<h2>Aufräumen</h2>
<p>Verwirf Synths, wenn du fertig bist, um Ressourcen freizugeben:</p>
<pre><code class="language-js">function cleanup() {
  if (softSynth) softSynth.dispose()
  if (subSynth) subSynth.dispose()
  if (noiseSynth) noiseSynth.dispose()
  if (sfxGain) sfxGain.dispose()
}
</code></pre>
<h2>Typische Fehler</h2>
<p><strong>Nicht auf Tone.start() warten</strong> — Audio spielt nicht, bevor der Kontext bei einer Nutzerinteraktion gestartet wurde.</p>
<p><strong>Für jeden Klang neue Synths erzeugen</strong> — Erzeuge Synths einmal beim Init und nutz sie wieder. Jedes Mal neue zu erzeugen leckt Speicher.</p>
<p><strong>Zu viele überlappende Klänge</strong> — Nutz Ratenbegrenzung und Entdopplung, damit kein Klangmatsch entsteht.</p>
<p><strong>Harte Frequenzen</strong> — Nutz Tiefpassfilter und weichere Oszillatortypen (sine, triangle) für Klänge, die Spieler nicht nerven.</p>
<h2>Mehr Ressourcen</h2>
<p><a href="/de/tutorials/web-audio-api-games">Web-Audio-API für Spiele</a> behandelt die darunterliegende API und das Laden von Audiodateien.</p>
<p><a href="https://tonejs.github.io/">Die Dokumentation von Tone.js</a> enthält die vollständige API-Referenz.</p>
<p><a href="/de/guides/game-assets-guide">Wo du kostenlose Game-Assets findest</a> nennt Anbieter für Audio und Soundeffekte wie Freesound.</p>
<p><a href="/de/tutorials/mobile-friendly-web-games">Mobilfreundliche Web-Spiele</a> behandelt iOS-Audiobeschränkungen und Muster zum Freischalten per Touch.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web-Audio-API für Spiele]]></title>
            <link>https://app.cinevva.com/de/tutorials/web-audio-api-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/web-audio-api-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Spiel-Audio mit der Web-Audio-API umsetzen: Soundeffekte, Musik, räumlicher Klang und Mixing. Praxisnahe Muster, die funktionieren.]]></description>
            <content:encoded><![CDATA[<h1>Web-Audio-API für Spiele</h1>
<p>Guter Ton verwandelt ein Spiel. Ein stummes Spiel wirkt leblos. Die Web-Audio-API gibt dir Soundeffekte mit geringer Latenz, Musikwiedergabe und sogar räumlichen 3D-Klang, alles im Browser.</p>
<p><strong>Probier es aus (klick einen Button, um synthetisierte Klänge zu hören):</strong></p>
<AudioDemo />
<h2>Das Autoplay-Problem</h2>
<p>Bevor du irgendetwas machst, musst du die Autoplay-Beschränkungen verstehen. Browser blockieren Audio, bis der Nutzer mit deiner Seite interagiert. Das ist gut so (niemand will, dass Webseiten beim Laden losdröhnen), heißt aber, dass du Audio als Reaktion auf einen Klick, Tipp oder Tastendruck initialisieren musst.</p>
<pre><code class="language-js">let audioCtx = null

function initAudio() {
  if (!audioCtx) {
    audioCtx = new AudioContext()
  }
  if (audioCtx.state === 'suspended') {
    audioCtx.resume()
  }
  return audioCtx
}

// Initialize on first interaction
document.addEventListener('click', () =&gt; initAudio(), { once: true })
document.addEventListener('keydown', () =&gt; initAudio(), { once: true })
</code></pre>
<p>Versuchst du vorher etwas abzuspielen, scheitert es still oder der AudioContext bleibt suspendiert. Das erwischt viele Entwickler kalt.</p>
<h2>Sounds laden</h2>
<p>Audiodateien müssen vor der Wiedergabe dekodiert werden. Datei holen, dekodieren und den Buffer zur Wiederverwendung behalten:</p>
<pre><code class="language-js">async function loadSound(url) {
  const response = await fetch(url)
  const arrayBuffer = await response.arrayBuffer()
  const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
  return audioBuffer
}
</code></pre>
<p>Das Dekodieren dauert, lade also alle Sounds während eines Ladebildschirms und nicht erst, wenn du sie brauchst.</p>
<h2>Sounds abspielen</h2>
<p>Zum Abspielen erzeugst du eine Buffer-Quelle, verbindest sie über einen Gain-Knoten zur Lautstärkeregelung und startest sie:</p>
<pre><code class="language-js">function playSound(buffer, volume = 1) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  
  const gainNode = audioCtx.createGain()
  gainNode.gain.value = volume
  
  source.connect(gainNode)
  gainNode.connect(audioCtx.destination)
  
  source.start(0)
  return source
}
</code></pre>
<p>Buffer-Quellen sind Einmalgebrauch. Ist eine Quelle durchgelaufen, kannst du sie nicht neu starten. Erzeuge bei jedem Abspielen eine neue. Das fühlt sich verschwenderisch an, ist aber die Arbeitsweise der API, und Browser optimieren dafür.</p>
<h2>Ein Sound-Manager</h2>
<p>Für ein echtes Spiel packst du alles in eine Manager-Klasse:</p>
<pre><code class="language-js">class SoundManager {
  constructor() {
    this.ctx = null
    this.sounds = new Map()
    this.masterGain = null
  }
  
  init() {
    this.ctx = new AudioContext()
    this.masterGain = this.ctx.createGain()
    this.masterGain.connect(this.ctx.destination)
  }
  
  async load(name, url) {
    const response = await fetch(url)
    const buffer = await this.ctx.decodeAudioData(await response.arrayBuffer())
    this.sounds.set(name, buffer)
  }
  
  play(name, volume = 1) {
    const buffer = this.sounds.get(name)
    if (!buffer) return
    
    const source = this.ctx.createBufferSource()
    source.buffer = buffer
    
    const gain = this.ctx.createGain()
    gain.gain.value = volume
    
    source.connect(gain)
    gain.connect(this.masterGain)
    source.start(0)
    
    return source
  }
  
  setMasterVolume(v) {
    this.masterGain.gain.value = v
  }
}
</code></pre>
<p>Der Master-Gain-Knoten erlaubt dir einen globalen Lautstärkeregler. Alle Sounds laufen durch ihn.</p>
<h2>Hintergrundmusik</h2>
<p>Musik braucht Schleifen und die Möglichkeit zu stoppen oder auszublenden:</p>
<pre><code class="language-js">class MusicPlayer {
  constructor(ctx, masterGain) {
    this.ctx = ctx
    this.masterGain = masterGain
    this.currentTrack = null
    this.gain = ctx.createGain()
    this.gain.connect(masterGain)
  }
  
  play(buffer, volume = 0.5) {
    this.stop()
    
    this.currentTrack = this.ctx.createBufferSource()
    this.currentTrack.buffer = buffer
    this.currentTrack.loop = true
    this.currentTrack.connect(this.gain)
    this.gain.gain.value = volume
    this.currentTrack.start(0)
  }
  
  stop() {
    if (this.currentTrack) {
      this.currentTrack.stop()
      this.currentTrack = null
    }
  }
  
  fadeOut(duration = 1) {
    this.gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration)
  }
}
</code></pre>
<p>Die Methode <code>linearRampToValueAtTime</code> gibt dir weiche Blenden statt abrupter Schnitte.</p>
<h2>Räumlicher Klang</h2>
<p>Für 3D-Spiele kannst du Klänge im Raum positionieren. Sie werden leiser, je weiter sie sich vom Hörer entfernen:</p>
<pre><code class="language-js">function playSpatialSound(buffer, x, y, z) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  
  const panner = audioCtx.createPanner()
  panner.panningModel = 'HRTF'
  panner.distanceModel = 'inverse'
  panner.refDistance = 1
  panner.maxDistance = 100
  panner.positionX.value = x
  panner.positionY.value = y
  panner.positionZ.value = z
  
  source.connect(panner)
  panner.connect(audioCtx.destination)
  source.start(0)
}

function updateListener(x, y, z, fx, fy, fz) {
  const listener = audioCtx.listener
  listener.positionX.value = x
  listener.positionY.value = y
  listener.positionZ.value = z
  listener.forwardX.value = fx
  listener.forwardY.value = fy
  listener.forwardZ.value = fz
  listener.upX.value = 0
  listener.upY.value = 1
  listener.upZ.value = 0
}
</code></pre>
<p>Ruf <code>updateListener</code> in jedem Frame mit der Kamera- oder Spielerposition auf. Der Panner passt Lautstärke und Stereopanorama automatisch nach Entfernung und Richtung an.</p>
<h2>Schnellfeuer-Sounds</h2>
<p>Bei Klängen, die vielfach pro Sekunde spielen (Schüsse, Schritte), sollen sich überlappende Instanzen nicht stapeln. Ein einfaches Pool-Muster hilft:</p>
<pre><code class="language-js">class SoundPool {
  constructor(ctx, buffer, size = 8) {
    this.sources = []
    this.index = 0
    this.ctx = ctx
    this.buffer = buffer
    this.size = size
  }
  
  play(volume = 1) {
    const source = this.ctx.createBufferSource()
    source.buffer = this.buffer
    
    const gain = this.ctx.createGain()
    gain.gain.value = volume
    
    source.connect(gain)
    gain.connect(this.ctx.destination)
    source.start(0)
    
    this.index = (this.index + 1) % this.size
  }
}
</code></pre>
<p>Der Pool begrenzt, wie viele Instanzen desselben Klangs gleichzeitig laufen. Ist das Limit erreicht, wird die älteste ersetzt.</p>
<h2>Audioformate</h2>
<p>Verschiedene Formate passen zu verschiedenen Situationen:</p>
<p>MP3 läuft überall und eignet sich für Musik. OGG Vorbis liefert bei gleicher Dateigröße bessere Qualität. Safari war lange der Nachzügler, hat native Ogg-Vorbis-Wiedergabe aber in Safari 18.4 ergänzt, alle aktuellen großen Browser unterstützen es also. Einen MP3-Rückfall willst du trotzdem, wenn dir ältere Safari-Versionen wichtig sind. AAC/M4A funktioniert gut auf Apple-Geräten. WAV ist unkomprimiert und groß, dekodiert aber sofort, was für kurze Soundeffekte gut ist, wenn du dir die Dateigröße leisten kannst. WebM/Opus hat das beste Verhältnis von Qualität zu Größe, funktioniert aber nur in modernen Browsern.</p>
<p>Für maximale Kompatibilität bietest du Rückfallwege an:</p>
<pre><code class="language-js">const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'
</code></pre>
<h2>Nützliche Kniffe</h2>
<p><strong>Tonhöhenvariation</strong> lässt wiederholte Klänge weniger mechanisch wirken:</p>
<pre><code class="language-js">function playWithPitchVariation(buffer, variance = 0.1) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  source.playbackRate.value = 1 + (Math.random() - 0.5) * variance
  source.connect(audioCtx.destination)
  source.start(0)
}
</code></pre>
<p>Jeder Schuss und jeder Schritt klingt leicht anders.</p>
<p><strong>Ducking</strong> senkt die Musiklautstärke bei Dialogen oder wichtigen Klängen:</p>
<pre><code class="language-js">function duckMusic(musicGain, duration = 0.3) {
  musicGain.gain.linearRampToValueAtTime(0.2, audioCtx.currentTime + duration)
}

function unduckMusic(musicGain, duration = 0.3) {
  musicGain.gain.linearRampToValueAtTime(1, audioCtx.currentTime + duration)
}
</code></pre>
<h2>Stolperfallen unter iOS</h2>
<p>iOS hat strengere Audio-Beschränkungen als andere Plattformen. Der AudioContext muss während einer Nutzergeste erzeugt und fortgesetzt werden. Manche iOS-Versionen verlangen außerdem, dass du während der Geste einen Klang abspielst, selbst einen stummen, und nicht nur den Kontext erzeugst. Wenn Audio auf dem Desktop läuft, unter iOS aber nicht, liegt es wahrscheinlich daran.</p>
<h2>Mehr Ressourcen</h2>
<p><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a> behandelt Asset-Kompression, auch für Audio.</p>
<p><a href="/de/tutorials/mobile-friendly-web-games">Mobilfreundliche Web-Spiele</a> geht ausführlicher auf iOS-spezifische Probleme ein.</p>
<p><a href="/de/tutorials/pwa-offline-games">PWA für Offline-Spiele</a> zeigt, wie du Audiodateien fürs Offline-Spielen cachst.</p>
<p><a href="/de/tutorials/tone-js-game-audio">Tone.js für Spiel-Audio</a> behandelt prozedurale Klangerzeugung und Musiksysteme mit einer Bibliothek auf höherer Ebene.</p>
<p><a href="/de/guides/game-assets-guide">Wo du kostenlose Game-Assets findest</a> listet kostenlose Quellen für Soundeffekte und Musik wie Freesound und Poly Haven.</p>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API">MDN: Web Audio API</a> — vollständige API-Referenz</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioContext">MDN: AudioContext</a> — Audio-Kontexte erzeugen und verwalten</li>
<li><a href="https://www.w3.org/TR/webaudio/">Web Audio API spec</a> — W3C-Spezifikation</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web Workers für Spiellogik]]></title>
            <link>https://app.cinevva.com/de/tutorials/web-workers-game-logic</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/web-workers-game-logic</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Schwere Berechnungen in Web Workers auslagern: Physik, Pathfinding, KI und prozedurale Generierung, ohne den Haupt-Thread zu blockieren.]]></description>
            <content:encoded><![CDATA[<h1>Web Workers für Spiellogik</h1>
<p>Mit Web Workers läuft JavaScript in einem Hintergrund-Thread. Für Spiele heißt das: Physik, KI und prozedurale Generierung laufen, ohne dass Frames wegbrechen.</p>
<h2>1) Wann Workers sinnvoll sind</h2>
<p>Gute Kandidaten:</p>
<ul>
<li>Physiksimulation</li>
<li>Pathfinding (A*, Navmesh)</li>
<li>KI-Entscheidungen</li>
<li>Prozedurale Generierung</li>
<li>Asset-Verarbeitung (Bildbearbeitung, Kompression)</li>
<li>Aufwendige Mathematik (FFT, Kollisionserkennung)</li>
</ul>
<p>Weniger geeignet:</p>
<ul>
<li>Rendering (Workers kommen nicht direkt an DOM oder Canvas)</li>
<li>Sehr kleine Aufgaben (der Thread-Overhead lohnt nicht)</li>
</ul>
<h2>2) Einen einfachen Worker erstellen</h2>
<p><strong>worker.js:</strong></p>
<pre><code class="language-js">self.onmessage = (e) =&gt; {
  const { type, data } = e.data
  
  if (type === 'calculate') {
    const result = heavyCalculation(data)
    self.postMessage({ type: 'result', data: result })
  }
}

function heavyCalculation(input) {
  // Expensive work here
  return input * 2
}
</code></pre>
<p><strong>main.js:</strong></p>
<pre><code class="language-js">const worker = new Worker('worker.js')

worker.onmessage = (e) =&gt; {
  const { type, data } = e.data
  if (type === 'result') {
    console.log('Got result:', data)
  }
}

worker.postMessage({ type: 'calculate', data: 42 })
</code></pre>
<h2>3) Inline-Workers (ohne eigene Datei)</h2>
<pre><code class="language-js">function createInlineWorker(fn) {
  const blob = new Blob([`(${fn.toString()})()`], { type: 'text/javascript' })
  return new Worker(URL.createObjectURL(blob))
}

const worker = createInlineWorker(() =&gt; {
  self.onmessage = (e) =&gt; {
    const result = e.data * 2
    self.postMessage(result)
  }
})
</code></pre>
<h2>4) Workers als ES-Module</h2>
<p>Du kannst Worker-Code als ES-Modul schreiben und darin <code>import</code> statt <code>importScripts</code> nutzen. Übergib dazu <code>{ type: 'module' }</code> an den Konstruktor:</p>
<pre><code class="language-js">const worker = new Worker('physics-worker.js', { type: 'module' })
</code></pre>
<p>Im Worker kannst du dann gemeinsame Helfer für Mathematik, Kollision oder KI genauso importieren wie im Haupt-Thread, die Spiellogik wird also nicht doppelt gepflegt. Modul-Workers werden in Chrome und Edge ab 80, Safari ab 15 und Firefox ab 114 unterstützt.</p>
<h2>5) Beispiel Physik-Worker</h2>
<p><strong>physics-worker.js:</strong></p>
<pre><code class="language-js">const bodies = []
const FIXED_DT = 1 / 60

self.onmessage = (e) =&gt; {
  const { type, data } = e.data
  
  switch (type) {
    case 'init':
      initWorld(data)
      break
    case 'step':
      step()
      break
    case 'addBody':
      bodies.push(data)
      break
  }
}

function initWorld(config) {
  // Initialize physics world
}

function step() {
  // Update physics
  for (const body of bodies) {
    body.vy += 9.8 * FIXED_DT // Gravity
    body.x += body.vx * FIXED_DT
    body.y += body.vy * FIXED_DT
  }
  
  // Send positions back
  self.postMessage({
    type: 'positions',
    data: bodies.map(b =&gt; ({ id: b.id, x: b.x, y: b.y, rotation: b.rotation }))
  })
}
</code></pre>
<h2>6) Transferable Objects für Performance</h2>
<p>Große Daten lassen sich ohne Kopieren übergeben:</p>
<pre><code class="language-js">// Main thread
const positions = new Float32Array(1000)
worker.postMessage(positions, [positions.buffer])
// positions is now unusable here (transferred)

// Worker
self.onmessage = (e) =&gt; {
  const positions = e.data
  // Work with positions
  self.postMessage(positions, [positions.buffer])
}
</code></pre>
<h2>7) Pathfinding-Worker</h2>
<p><strong>pathfinding-worker.js:</strong></p>
<pre><code class="language-js">let grid = null

self.onmessage = (e) =&gt; {
  const { type, data } = e.data
  
  if (type === 'setGrid') {
    grid = data
  }
  
  if (type === 'findPath') {
    const path = aStar(data.start, data.end, grid)
    self.postMessage({ type: 'path', id: data.id, path })
  }
}

function aStar(start, end, grid) {
  // A* implementation
  const openSet = [start]
  const cameFrom = new Map()
  const gScore = new Map()
  gScore.set(key(start), 0)
  
  while (openSet.length &gt; 0) {
    // ... A* logic
  }
  
  return reconstructPath(cameFrom, end)
}

function key(pos) {
  return `${pos.x},${pos.y}`
}
</code></pre>
<h2>8) Worker-Pool für parallele Aufgaben</h2>
<pre><code class="language-js">class WorkerPool {
  constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
    this.workers = []
    this.queue = []
    this.available = []
    
    for (let i = 0; i &lt; size; i++) {
      const worker = new Worker(workerUrl)
      worker.onmessage = (e) =&gt; this.handleResult(worker, e)
      this.workers.push(worker)
      this.available.push(worker)
    }
  }
  
  run(data) {
    return new Promise((resolve) =&gt; {
      const task = { data, resolve }
      
      if (this.available.length &gt; 0) {
        this.dispatch(this.available.pop(), task)
      } else {
        this.queue.push(task)
      }
    })
  }
  
  dispatch(worker, task) {
    worker._currentTask = task
    worker.postMessage(task.data)
  }
  
  handleResult(worker, e) {
    const task = worker._currentTask
    task.resolve(e.data)
    
    if (this.queue.length &gt; 0) {
      this.dispatch(worker, this.queue.shift())
    } else {
      this.available.push(worker)
    }
  }
  
  terminate() {
    this.workers.forEach(w =&gt; w.terminate())
  }
}
</code></pre>
<h2>9) SharedArrayBuffer für Echtzeit-Synchronisation</h2>
<p>Mit aktivierter Cross-Origin-Isolation kannst du Speicher teilen:</p>
<pre><code class="language-js">// Main thread
const shared = new SharedArrayBuffer(1024)
const positions = new Float32Array(shared)

worker.postMessage({ type: 'init', buffer: shared })

// Worker reads/writes directly to shared memory
// No postMessage overhead for position updates
</code></pre>
<h2>10) OffscreenCanvas für Workers</h2>
<p>Rendern im Worker. OffscreenCanvas ist inzwischen als Baseline breit verfügbar, in Chrome, Edge, Firefox und Safari (Safari unterstützt es ab 17.0 auf macOS und iOS):</p>
<pre><code class="language-js">// Main thread
const canvas = document.getElementById('game')
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])

// Worker
self.onmessage = (e) =&gt; {
  const canvas = e.data.canvas
  const ctx = canvas.getContext('2d')
  
  function render() {
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    // Draw...
    requestAnimationFrame(render)
  }
  render()
}
</code></pre>
<h2>11) Fehlerbehandlung</h2>
<pre><code class="language-js">worker.onerror = (e) =&gt; {
  console.error('Worker error:', e.message, e.filename, e.lineno)
}

// In worker
self.onerror = (e) =&gt; {
  self.postMessage({ type: 'error', message: e.message })
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/coop-coep-sharedarraybuffer">Wasm-Threads (SharedArrayBuffer) aktivieren</a></li>
<li><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a></li>
<li><a href="/de/tutorials/game-physics-libraries">Physik-Bibliotheken für Spiele</a> — Rapier oder Cannon-es in einem Worker betreiben</li>
<li><a href="/de/tutorials/streaming-asset-loading">Streaming von Assets</a> — Assets abseits des Haupt-Threads dekodieren</li>
<li><a href="/de/guides/web-games-stack-2026">Tech-Stack für Web-Spiele 2026</a> — wo Workers in den Stack aus WebGL, WebGPU und Wasm passen</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API">MDN: Web Workers API</a> — vollständige API-Referenz</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">MDN: Using Web Workers</a> — Schritt-für-Schritt-Anleitung</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects">MDN: Transferable objects</a> — Datenübergabe ohne Kopie</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">MDN: SharedArrayBuffer</a> — geteilter Speicher zwischen Threads</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[WebGL-Grundlagen für Spieleentwickler]]></title>
            <link>https://app.cinevva.com/de/tutorials/webgl-fundamentals-for-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/webgl-fundamentals-for-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[WebGL-Basics für Spiele lernen: Shader, Buffer, Texturen und die Render-Schleife. Praxisnaher Code zum Kopieren und Weiterbauen.]]></description>
            <content:encoded><![CDATA[<h1>WebGL-Grundlagen für Spieleentwickler</h1>
<p>WebGL gibt dir direkten GPU-Zugriff im Browser. Es ist die Grundlage für 3D-Web-Spiele und für performantes 2D. Dieses Tutorial deckt das Wesentliche ab.</p>
<h2>1) Einen WebGL-Kontext holen</h2>
<pre><code class="language-js">const canvas = document.getElementById('game')
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')

if (!gl) {
  // Fallback or error message
  console.error('WebGL not supported')
}
</code></pre>
<p>Nimm <code>webgl2</code>, wenn verfügbar. Es wird breit unterstützt und bietet die besseren Funktionen.</p>
<h2>2) Die Render-Pipeline (vereinfacht)</h2>
<ol>
<li>Der <strong>Vertex-Shader</strong> läuft einmal pro Vertex. Er positioniert deine Geometrie.</li>
<li>Der <strong>Fragment-Shader</strong> läuft einmal pro Pixel. Er färbt die Ausgabe.</li>
<li>Buffer halten Daten (Positionen, UVs, Farben).</li>
<li>Texturen sind Bilder, die Shader abtasten.</li>
</ol>
<h2>3) Ein minimales Shader-Paar</h2>
<p><strong>Vertex-Shader:</strong></p>
<pre><code class="language-glsl">#version 300 es
in vec2 a_position;
void main() {
  gl_Position = vec4(a_position, 0.0, 1.0);
}
</code></pre>
<p><strong>Fragment-Shader:</strong></p>
<pre><code class="language-glsl">#version 300 es
precision mediump float;
out vec4 fragColor;
void main() {
  fragColor = vec4(1.0, 0.5, 0.2, 1.0); // orange
}
</code></pre>
<h2>4) Shader kompilieren und linken</h2>
<pre><code class="language-js">function createShader(gl, type, source) {
  const shader = gl.createShader(type)
  gl.shaderSource(shader, source)
  gl.compileShader(shader)
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(shader))
    gl.deleteShader(shader)
    return null
  }
  return shader
}

function createProgram(gl, vs, fs) {
  const program = gl.createProgram()
  gl.attachShader(program, vs)
  gl.attachShader(program, fs)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    return null
  }
  return program
}
</code></pre>
<h2>5) Buffer anlegen</h2>
<pre><code class="language-js">const positions = new Float32Array([
  -0.5, -0.5,
   0.5, -0.5,
   0.0,  0.5,
])

const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW)
</code></pre>
<h2>6) Buffer mit Shadern verbinden</h2>
<pre><code class="language-js">const positionLoc = gl.getAttribLocation(program, 'a_position')
gl.enableVertexAttribArray(positionLoc)
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0)
</code></pre>
<h2>7) Die Spielschleife</h2>
<pre><code class="language-js">function render() {
  gl.viewport(0, 0, canvas.width, canvas.height)
  gl.clearColor(0, 0, 0, 1)
  gl.clear(gl.COLOR_BUFFER_BIT)

  gl.useProgram(program)
  gl.drawArrays(gl.TRIANGLES, 0, 3)

  requestAnimationFrame(render)
}
render()
</code></pre>
<h2>8) Texturen laden</h2>
<pre><code class="language-js">function loadTexture(gl, url) {
  const texture = gl.createTexture()
  gl.bindTexture(gl.TEXTURE_2D, texture)

  // Placeholder pixel until image loads
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
    new Uint8Array([255, 0, 255, 255]))

  const img = new Image()
  img.onload = () =&gt; {
    gl.bindTexture(gl.TEXTURE_2D, texture)
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
    gl.generateMipmap(gl.TEXTURE_2D)
  }
  img.src = url
  return texture
}
</code></pre>
<h2>9) Typische Stolperfallen</h2>
<ul>
<li><strong>Zweierpotenz-Texturen</strong> sind in WebGL 2 nicht zwingend, Mipmaps funktionieren mit ihnen aber besser.</li>
<li><strong>Verlorener Kontext</strong>: Behandle <code>webglcontextlost</code>-Events. Mobile Browser räumen Kontexte bei Speicherdruck ab.</li>
<li><strong>Float-Texturen</strong> brauchen eine Prüfung auf Erweiterungen (<code>EXT_color_buffer_float</code>).</li>
</ul>
<h2>10) Wann eine Bibliothek sinnvoll ist</h2>
<p>Rohes WebGL ist geschwätzig. Für die meisten Spiele lohnt ein Blick auf:</p>
<ul>
<li><strong>Three.js</strong> — vollwertiges 3D</li>
<li><strong>PixiJS</strong> — schnelles 2D-Rendering</li>
<li><strong>PlayCanvas</strong> — Game-Engine mit Editor</li>
</ul>
<p>Nimm rohes WebGL, wenn du brauchst:</p>
<ul>
<li>maximale Kontrolle</li>
<li>einen eigenen Renderer</li>
<li>ein echtes Verständnis davon, wie es funktioniert</li>
</ul>
<p>Eine Anmerkung für 2026: WebGPU wird inzwischen in jedem großen Browser standardmäßig ausgeliefert (Chrome und Edge seit 113, Firefox 141 unter Windows und 145 unter macOS sowie Safari auf iOS 26, iPadOS 26 und macOS Tahoe 26). Die Grundlagen hier gelten weiterhin, denn Bibliotheken wie Three.js betreiben ihren WebGPU-Renderer mit automatischem WebGL-2-Fallback, WebGL 2 bleibt also die verlässliche Basis. Wenn du heute einen Renderer von Grund auf neu beginnst, lies das WebGPU-Tutorial parallel zu diesem, bevor du dich auf eine API festlegst.</p>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/webgpu-getting-started">WebGPU für Spiele</a></li>
<li><a href="/de/tutorials/canvas-2d-game-loop">Canvas-2D-Spielschleife</a></li>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/guides/web-games-stack-2026">Tech-Stack für Web-Spiele 2026</a> — wann WebGL, WebGPU oder Wasm die richtige Wahl ist</li>
<li><a href="/de/tutorials/game-physics-libraries">Physik-Bibliotheken für Spiele</a> — Physik-Engines, die über WebGL rendern</li>
<li><a href="/de/guides/web-game-engines-comparison">Web-Game-Engines im Vergleich</a> — Three.js, PixiJS, PlayCanvas und andere</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API">MDN: WebGL API</a> — vollständige API-Referenz</li>
<li><a href="https://webgl2fundamentals.org/">WebGL2 Fundamentals</a> — ausführliche Tutorial-Reihe zu WebGL2</li>
<li><a href="https://thebookofshaders.com/">The Book of Shaders</a> — interaktiver Leitfaden zu GLSL-Fragment-Shadern</li>
<li><a href="https://threejs.org/docs/">Three.js documentation</a> — das populärste WebGL-Framework</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[WebGPU-Einstieg für Spieleentwickler]]></title>
            <link>https://app.cinevva.com/de/tutorials/webgpu-getting-started</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/webgpu-getting-started</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Einstieg in WebGPU: Device-Setup, Pipelines, Shader und Rendering. Die moderne GPU-API für die nächste Generation von Web-Spielen.]]></description>
            <content:encoded><![CDATA[<h1>WebGPU-Einstieg für Spieleentwickler</h1>
<p>WebGPU ist der Nachfolger von WebGL. Es ist hardwarenäher, expliziter und auf moderne GPUs ausgelegt. Wenn du 2026 ein neues 3D-Projekt startest, lohnt es sich, WebGPU zu lernen.</p>
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;border-radius:8px;margin:1.5rem 0">
<iframe src="https://www.youtube.com/embed/KTFFdZSDiTU" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" allowfullscreen></iframe>
</div>
<p style="font-size:0.9rem;color:var(--vp-c-text-2);margin-top:-0.5rem">WebGPU-Tutorial: 10 Projekte vom Setup bis zum 3D-Rendering mit Licht und Animation</p>
<h2>1) Browser-Unterstützung prüfen</h2>
<pre><code class="language-js">if (!navigator.gpu) {
  console.error('WebGPU not supported')
}
</code></pre>
<p>Stand 2026 ist WebGPU in Chrome, Edge, Firefox und Safari verfügbar.</p>
<h2>2) Ein Device holen</h2>
<pre><code class="language-js">const adapter = await navigator.gpu.requestAdapter()
const device = await adapter.requestDevice()
</code></pre>
<p>Der Adapter steht für die physische GPU. Das Device ist deine Schnittstelle dorthin.</p>
<p>Beide Aufrufe können fehlschlagen. <code>requestAdapter()</code> liefert <code>null</code>, wenn das System keine nutzbare GPU hat, und <code>requestDevice()</code> kann rejecten. Sichere das ab, bevor du weitermachst:</p>
<pre><code class="language-js">const adapter = await navigator.gpu.requestAdapter()
if (!adapter) throw new Error('No suitable GPU adapter found')
const device = await adapter.requestDevice()
</code></pre>
<p>Der Adapter ist außerdem verbraucht, sobald du ein Device daraus erzeugst. Fordere also einen neuen an, wenn du ein weiteres Device brauchst.</p>
<h2>3) Das Canvas konfigurieren</h2>
<pre><code class="language-js">const canvas = document.getElementById('game')
const context = canvas.getContext('webgpu')
const format = navigator.gpu.getPreferredCanvasFormat()

context.configure({
  device,
  format,
  alphaMode: 'premultiplied',
})
</code></pre>
<h2>4) Shader in WGSL schreiben</h2>
<p>WebGPU nutzt WGSL (WebGPU Shading Language), nicht GLSL.</p>
<pre><code class="language-wgsl">@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -&gt; @builtin(position) vec4f {
  var pos = array&lt;vec2f, 3&gt;(
    vec2f( 0.0,  0.5),
    vec2f(-0.5, -0.5),
    vec2f( 0.5, -0.5),
  );
  return vec4f(pos[idx], 0.0, 1.0);
}

@fragment
fn fs_main() -&gt; @location(0) vec4f {
  return vec4f(1.0, 0.5, 0.2, 1.0); // orange
}
</code></pre>
<h2>5) Ein Shader-Modul erstellen</h2>
<pre><code class="language-js">const shaderModule = device.createShaderModule({
  code: wgslSource,
})
</code></pre>
<h2>6) Eine Render-Pipeline erstellen</h2>
<pre><code class="language-js">const pipeline = device.createRenderPipeline({
  layout: 'auto',
  vertex: {
    module: shaderModule,
    entryPoint: 'vs_main',
  },
  fragment: {
    module: shaderModule,
    entryPoint: 'fs_main',
    targets: [{ format }],
  },
})
</code></pre>
<h2>7) Ein Frame rendern</h2>
<pre><code class="language-js">function render() {
  const commandEncoder = device.createCommandEncoder()
  const textureView = context.getCurrentTexture().createView()

  const renderPass = commandEncoder.beginRenderPass({
    colorAttachments: [{
      view: textureView,
      clearValue: { r: 0, g: 0, b: 0, a: 1 },
      loadOp: 'clear',
      storeOp: 'store',
    }],
  })

  renderPass.setPipeline(pipeline)
  renderPass.draw(3)
  renderPass.end()

  device.queue.submit([commandEncoder.finish()])
  requestAnimationFrame(render)
}
render()
</code></pre>
<h2>8) Buffer in WebGPU</h2>
<pre><code class="language-js">const vertices = new Float32Array([
  -0.5, -0.5,
   0.5, -0.5,
   0.0,  0.5,
])

const vertexBuffer = device.createBuffer({
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
})

device.queue.writeBuffer(vertexBuffer, 0, vertices)
</code></pre>
<h2>9) Bind Groups für Uniforms</h2>
<pre><code class="language-js">const uniformBuffer = device.createBuffer({
  size: 64,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer },
  }],
})
</code></pre>
<h2>10) WebGPU gegen WebGL</h2>
<table>
<thead>
<tr>
<th>Aspekt</th>
<th>WebGL</th>
<th>WebGPU</th>
</tr>
</thead>
<tbody>
<tr>
<td>API-Stil</td>
<td>OpenGL-artig, zustandsbehaftet</td>
<td>Modern, explizit</td>
</tr>
<tr>
<td>Shader</td>
<td>GLSL</td>
<td>WGSL</td>
</tr>
<tr>
<td>Compute</td>
<td>Eingeschränkt (Erweiterungen)</td>
<td>Von Haus aus dabei</td>
</tr>
<tr>
<td>Multithreading</td>
<td>Schwierig</td>
<td>Dafür entworfen</td>
</tr>
<tr>
<td>Browser-Unterstützung</td>
<td>Überall</td>
<td>Alle großen Browser (Chrome, Edge, Safari 26, Firefox)</td>
</tr>
</tbody>
</table>
<h2>Wann WebGPU sinnvoll ist</h2>
<ul>
<li>Neue Projekte für moderne Browser</li>
<li>Rechenlastige Aufgaben (Physik, KI, prozedurale Generierung)</li>
<li>Wenn du feingranulare Kontrolle über GPU-Ressourcen brauchst</li>
</ul>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/webgl-fundamentals-for-games">WebGL-Grundlagen</a></li>
<li><a href="/de/tutorials/web-workers-game-logic">Web Workers für Spiellogik</a></li>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/guides/web-games-stack-2026">Tech-Stack für Web-Spiele 2026</a> — Entscheidungsrahmen für WebGL, WebGPU und Wasm</li>
<li><a href="/de/tutorials/coop-coep-sharedarraybuffer">COOP/COEP und SharedArrayBuffer</a> — nötige Header für Wasm-Threading mit WebGPU-Compute</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://www.w3.org/TR/webgpu/">WebGPU specification</a> — W3C-Spezifikation</li>
<li><a href="https://www.w3.org/TR/WGSL/">WGSL specification</a> — Spezifikation der WebGPU Shading Language</li>
<li><a href="https://webgpufundamentals.org/">WebGPU Fundamentals</a> — ausführliche Tutorial-Reihe</li>
<li><a href="https://codelabs.developers.google.com/your-first-webgpu-app">Your first WebGPU app</a> — Google-Codelab zum Mitmachen</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[WebSocket-Multiplayer-Grundlagen]]></title>
            <link>https://app.cinevva.com/de/tutorials/websocket-multiplayer-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/websocket-multiplayer-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Echtzeit-Multiplayer mit WebSockets bauen: Verbindungsverwaltung, Nachrichtenprotokolle und Synchronisationsmuster für Web-Spiele.]]></description>
            <content:encoded><![CDATA[<h1>WebSocket-Multiplayer-Grundlagen</h1>
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;border-radius:8px;margin:1.5rem 0">
<iframe src="https://www.youtube.com/embed/cXxEiWudIUY" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" allowfullscreen></iframe>
</div>
<p style="font-size:0.9rem;color:var(--vp-c-text-2);margin-top:-0.5rem">Ein Multiplayer-Spiel mit WebSockets bauen: Serverarchitektur, Spielräume und Zustandssynchronisation</p>
<p>WebSockets ermöglichen Echtzeitkommunikation für Multiplayer-Spiele. Dieses Tutorial behandelt die Grundlagen auf der Client-Seite.</p>
<h2>1) Mit einem Server verbinden</h2>
<pre><code class="language-js">const ws = new WebSocket('wss://game-server.example.com')

ws.onopen = () =&gt; {
  console.log('Connected')
  ws.send(JSON.stringify({ type: 'join', name: 'Player1' }))
}

ws.onmessage = (event) =&gt; {
  const msg = JSON.parse(event.data)
  handleMessage(msg)
}

ws.onclose = () =&gt; {
  console.log('Disconnected')
}

ws.onerror = (err) =&gt; {
  console.error('WebSocket error:', err)
}
</code></pre>
<h2>2) Ein Nachrichtenprotokoll entwerfen</h2>
<p>Leg ein einfaches Protokoll fest:</p>
<pre><code class="language-js">// Client -&gt; Server
{ type: 'join', name: 'Player1' }
{ type: 'input', keys: { left: true, jump: true } }
{ type: 'chat', message: 'Hello!' }

// Server -&gt; Client
{ type: 'state', players: [...], entities: [...] }
{ type: 'playerJoined', id: 'abc', name: 'Player2' }
{ type: 'playerLeft', id: 'abc' }
</code></pre>
<h2>3) Eine Klasse für die Verbindungsverwaltung</h2>
<pre><code class="language-js">class GameConnection {
  constructor(url) {
    this.url = url
    this.ws = null
    this.handlers = new Map()
    this.reconnectDelay = 1000
  }
  
  connect() {
    this.ws = new WebSocket(this.url)
    
    this.ws.onopen = () =&gt; {
      this.reconnectDelay = 1000
      this.emit('connected')
    }
    
    this.ws.onmessage = (e) =&gt; {
      const msg = JSON.parse(e.data)
      this.emit(msg.type, msg)
    }
    
    this.ws.onclose = () =&gt; {
      this.emit('disconnected')
      this.scheduleReconnect()
    }
    
    this.ws.onerror = () =&gt; {
      this.ws.close()
    }
  }
  
  scheduleReconnect() {
    setTimeout(() =&gt; {
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
      this.connect()
    }, this.reconnectDelay)
  }
  
  send(type, data = {}) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, ...data }))
    }
  }
  
  on(type, handler) {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, [])
    }
    this.handlers.get(type).push(handler)
  }
  
  emit(type, data) {
    const handlers = this.handlers.get(type) || []
    handlers.forEach(h =&gt; h(data))
  }
  
  disconnect() {
    this.ws?.close()
  }
}
</code></pre>
<h2>4) Spielereingaben senden</h2>
<p>Sende Eingaben, keine Positionen. Lass den Server autoritativ sein:</p>
<pre><code class="language-js">const connection = new GameConnection('wss://server.example.com')

function sendInput(input) {
  connection.send('input', {
    left: input.left,
    right: input.right,
    jump: input.jump,
    shoot: input.shoot,
    seq: inputSequence++,
  })
}

// Send input at a fixed rate (e.g., 20 times/sec)
setInterval(() =&gt; {
  sendInput(currentInput)
}, 50)
</code></pre>
<h2>5) Spielzustand empfangen</h2>
<pre><code class="language-js">let gameState = { players: [], entities: [] }

connection.on('state', (msg) =&gt; {
  gameState = msg
})

connection.on('playerJoined', (msg) =&gt; {
  gameState.players.push({ id: msg.id, name: msg.name })
})

connection.on('playerLeft', (msg) =&gt; {
  gameState.players = gameState.players.filter(p =&gt; p.id !== msg.id)
})
</code></pre>
<h2>6) Interpolation für flüssige Bewegung</h2>
<p>Der Server schickt Momentaufnahmen. Interpoliere dazwischen:</p>
<pre><code class="language-js">class EntityInterpolator {
  constructor() {
    this.buffer = [] // { timestamp, state }
    this.renderDelay = 100 // ms behind server
  }
  
  addSnapshot(timestamp, state) {
    this.buffer.push({ timestamp, state })
    
    // Keep only recent snapshots
    while (this.buffer.length &gt; 10) {
      this.buffer.shift()
    }
  }
  
  getState(currentTime) {
    const renderTime = currentTime - this.renderDelay
    
    // Find surrounding snapshots
    let before = null
    let after = null
    
    for (let i = 0; i &lt; this.buffer.length - 1; i++) {
      if (this.buffer[i].timestamp &lt;= renderTime &amp;&amp; 
          this.buffer[i + 1].timestamp &gt;= renderTime) {
        before = this.buffer[i]
        after = this.buffer[i + 1]
        break
      }
    }
    
    if (!before || !after) {
      return this.buffer[this.buffer.length - 1]?.state
    }
    
    // Interpolate
    const t = (renderTime - before.timestamp) / (after.timestamp - before.timestamp)
    return this.lerp(before.state, after.state, t)
  }
  
  lerp(a, b, t) {
    return {
      x: a.x + (b.x - a.x) * t,
      y: a.y + (b.y - a.y) * t,
    }
  }
}
</code></pre>
<h2>7) Clientseitige Vorhersage</h2>
<p>Für reaktionsschnelle Steuerung sagst du lokal voraus und gleichst mit dem Server ab:</p>
<pre><code class="language-js">class PredictedPlayer {
  constructor() {
    this.position = { x: 0, y: 0 }
    this.pendingInputs = []
  }
  
  applyInput(input) {
    // Apply input locally
    if (input.left) this.position.x -= 5
    if (input.right) this.position.x += 5
    
    // Store for reconciliation
    this.pendingInputs.push({ seq: input.seq, input })
  }
  
  reconcile(serverState, lastProcessedSeq) {
    // Server confirmed position
    this.position = { ...serverState.position }
    
    // Remove confirmed inputs
    this.pendingInputs = this.pendingInputs.filter(i =&gt; i.seq &gt; lastProcessedSeq)
    
    // Re-apply unconfirmed inputs
    for (const pending of this.pendingInputs) {
      if (pending.input.left) this.position.x -= 5
      if (pending.input.right) this.position.x += 5
    }
  }
}
</code></pre>
<h2>8) Latenzanzeige</h2>
<pre><code class="language-js">class LatencyMonitor {
  constructor(connection) {
    this.connection = connection
    this.pingStart = 0
    this.latency = 0
    
    connection.on('pong', () =&gt; {
      this.latency = Date.now() - this.pingStart
    })
    
    setInterval(() =&gt; this.ping(), 1000)
  }
  
  ping() {
    this.pingStart = Date.now()
    this.connection.send('ping')
  }
  
  getLatency() {
    return this.latency
  }
}
</code></pre>
<h2>9) Binärnachrichten für Effizienz</h2>
<p>Für hochfrequente Updates nimmst du Binärdaten:</p>
<pre><code class="language-js">// Sending binary
const buffer = new ArrayBuffer(12)
const view = new DataView(buffer)
view.setFloat32(0, player.x)
view.setFloat32(4, player.y)
view.setUint32(8, inputFlags)
ws.send(buffer)

// Receiving binary
ws.binaryType = 'arraybuffer'
ws.onmessage = (e) =&gt; {
  if (e.data instanceof ArrayBuffer) {
    const view = new DataView(e.data)
    const x = view.getFloat32(0)
    const y = view.getFloat32(4)
    // ...
  }
}
</code></pre>
<h2>10) Ein einfacher Server (Node.js)</h2>
<pre><code class="language-js">import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })
const players = new Map()

wss.on('connection', (ws) =&gt; {
  const id = crypto.randomUUID()
  players.set(id, { id, x: 0, y: 0, ws })
  
  ws.on('message', (data) =&gt; {
    const msg = JSON.parse(data)
    
    if (msg.type === 'input') {
      const player = players.get(id)
      if (msg.left) player.x -= 5
      if (msg.right) player.x += 5
    }
  })
  
  ws.on('close', () =&gt; {
    players.delete(id)
    broadcast({ type: 'playerLeft', id })
  })
})

// Broadcast state 20 times/sec
setInterval(() =&gt; {
  const state = {
    type: 'state',
    players: Array.from(players.values()).map(p =&gt; ({
      id: p.id, x: p.x, y: p.y
    }))
  }
  broadcast(state)
}, 50)

function broadcast(msg) {
  const data = JSON.stringify(msg)
  for (const player of players.values()) {
    player.ws.send(data)
  }
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/ship-web-game-fast">Ein Web-Spiel ausliefern, das schnell lädt</a></li>
<li><a href="/de/tutorials/game-input-handling">Eingaben in Spielen</a></li>
<li><a href="/de/tutorials/web-workers-game-logic">Web Workers für Spiellogik</a></li>
<li><a href="/de/tutorials/coop-coep-sharedarraybuffer">COOP/COEP und SharedArrayBuffer</a> — Header, die SharedArrayBuffer im Multiplayer-Kontext braucht</li>
<li><a href="/de/guides/co-op-game-design">Koop-Spieldesign 2026</a> — warum &quot;Friend-Slop&quot;-Koop-Spiele explodieren und wo Browser-Multiplayer hineinpasst</li>
<li><a href="/de/guides/game-jams-hackathons">Game Jams und Hackathons</a> — Multiplayer-Jam-Spiele bekommen mehr Aufmerksamkeit</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API">MDN: WebSocket API</a> — Referenz zur WebSocket-API im Browser</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API">MDN: WebRTC API</a> — Peer-to-Peer-Verbindungen für geringere Latenz</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API">MDN: WebTransport API</a> — ein neuerer HTTP/3-Transport, der im März 2026 browserübergreifend Baseline erreichte, als Safari 26.4 die Unterstützung nachlieferte (in Chrome ab 97, Edge und Firefox ab 114 war sie schon da). Anders als ein einzelner TCP-basierter WebSocket läuft WebTransport über QUIC und bietet unzuverlässige Datagramme sowie mehrere unabhängige Streams, ein verlorenes Paket blockiert also nicht alles andere am Kopf der Warteschlange. Ein gängiges Muster ist, hochfrequente Positionsupdates über Datagramme zu schicken und Chat oder Bestenlisten auf einem zuverlässigen Stream zu lassen. WebSockets sind weiterhin die kompatibelste Wahl und alles oben gilt weiter, aber WebTransport ist für latenzempfindliche Spiele inzwischen eine Überlegung wert.</li>
<li><a href="https://socket.io/docs/v4/">Socket.IO documentation</a> — beliebte Wrapper-Bibliothek für WebSockets</li>
<li><a href="https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking">Valve Source Multiplayer Networking</a> — bewährte Netzwerkmuster aus Produktionsspielen</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[WebXR-Grundlagen für VR-Spiele]]></title>
            <link>https://app.cinevva.com/de/tutorials/webxr-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/de/tutorials/webxr-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Einstieg in WebXR: Session einrichten, rendern, Controller und Interaktionen. Bau VR-Erlebnisse, die im Browser laufen.]]></description>
            <content:encoded><![CDATA[<h1>WebXR-Grundlagen für VR-Spiele</h1>
<p>WebXR bringt VR und AR in den Browser. Spieler erleben dein Spiel im Headset, ohne etwas herunterzuladen.</p>
<h2>1) WebXR-Unterstützung prüfen</h2>
<pre><code class="language-js">async function checkXRSupport() {
  if (!navigator.xr) {
    return { vr: false, ar: false }
  }
  
  const vr = await navigator.xr.isSessionSupported('immersive-vr')
  const ar = await navigator.xr.isSessionSupported('immersive-ar')
  
  return { vr, ar }
}

// Usage
const support = await checkXRSupport()
if (support.vr) {
  showVRButton()
}
</code></pre>
<h2>2) Eine VR-Sitzung starten</h2>
<pre><code class="language-js">let xrSession = null
let xrRefSpace = null

async function startVR() {
  try {
    xrSession = await navigator.xr.requestSession('immersive-vr', {
      requiredFeatures: ['local-floor'],
      optionalFeatures: ['hand-tracking'],
    })
    
    xrSession.addEventListener('end', onSessionEnd)
    
    // Set up rendering
    const gl = canvas.getContext('webgl2', { xrCompatible: true })
    await xrSession.updateRenderState({
      baseLayer: new XRWebGLLayer(xrSession, gl),
    })
    
    // Get reference space
    xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
    
    // Start render loop
    xrSession.requestAnimationFrame(onXRFrame)
  } catch (err) {
    console.error('Failed to start VR:', err)
  }
}

function onSessionEnd() {
  xrSession = null
  xrRefSpace = null
}
</code></pre>
<h2>3) Die XR-Render-Schleife</h2>
<pre><code class="language-js">function onXRFrame(time, frame) {
  const session = frame.session
  session.requestAnimationFrame(onXRFrame)
  
  const pose = frame.getViewerPose(xrRefSpace)
  if (!pose) return
  
  const glLayer = session.renderState.baseLayer
  gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer)
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  
  // Render for each eye
  for (const view of pose.views) {
    const viewport = glLayer.getViewport(view)
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
    
    // Get camera matrices
    const viewMatrix = view.transform.inverse.matrix
    const projectionMatrix = view.projectionMatrix
    
    // Render scene with these matrices
    renderScene(viewMatrix, projectionMatrix)
  }
}
</code></pre>
<h2>4) Eingabequellen (Controller)</h2>
<pre><code class="language-js">xrSession.addEventListener('inputsourceschange', (e) =&gt; {
  for (const source of e.added) {
    console.log('Controller added:', source.handedness) // 'left' or 'right'
  }
  for (const source of e.removed) {
    console.log('Controller removed:', source.handedness)
  }
})

function processInput(frame) {
  for (const source of xrSession.inputSources) {
    if (!source.gamepad) continue
    
    const gamepad = source.gamepad
    
    // Trigger (usually index 0)
    const triggerPressed = gamepad.buttons[0]?.pressed
    const triggerValue = gamepad.buttons[0]?.value || 0
    
    // Grip (usually index 1)
    const gripPressed = gamepad.buttons[1]?.pressed
    
    // Thumbstick
    const thumbstickX = gamepad.axes[2] || 0
    const thumbstickY = gamepad.axes[3] || 0
    
    // A/B buttons (index 4, 5)
    const buttonA = gamepad.buttons[4]?.pressed
    const buttonB = gamepad.buttons[5]?.pressed
  }
}
</code></pre>
<h3>Nimm nicht an, dass jedes Gerät ein Gamepad hat</h3>
<p>Der Code oben liest <code>source.gamepad</code> für Trigger und Thumbsticks. Auf Controller-Headsets wie Meta Quest funktioniert das, auf Apple Vision Pro (WebXR ist in Safari unter visionOS 2 standardmäßig aktiv) oder Samsung Galaxy XR und anderen Android-XR-Geräten passiert dagegen nichts, weil dort Blick und Pinch oder Handtracking die primäre Eingabe sind. Auf diesen Headsets ist <code>source.gamepad</code> null und <code>session.inputSources</code> bleibt leer, bis der Nutzer tatsächlich pincht.</p>
<p>Für die eine Interaktion, die jedes Gerät garantiert, die &quot;primäre Aktion&quot;, lauschst du statt auf Tastenabfragen auf die <code>select</code>-Events. WebXR liefert <code>selectstart</code>, <code>select</code> und <code>selectend</code> gleichermaßen für einen Controller-Trigger, einen Hand-Pinch und einen Vision-Pro-Blick-Pinch, ein einziger Handler deckt also alles ab:</p>
<pre><code class="language-js">xrSession.addEventListener('selectstart', (e) =&gt; {
  // e.inputSource is the controller, hand, or transient pinch
  // e.frame lets you read its targetRaySpace pose
})
xrSession.addEventListener('select', (e) =&gt; {
  // primary action completed (trigger pulled, pinch released on target)
})
xrSession.addEventListener('selectend', (e) =&gt; {})
</code></pre>
<p>Behalte den Gamepad-Code für Thumbstick-Fortbewegung und Zusatztasten auf Controller-Headsets, sichere ihn aber mit <code>if (source.gamepad)</code> ab und leite deine zentrale Interaktion &quot;Auswählen, Greifen, Auslösen&quot; über diese Events, damit das Spiel auch auf hand- und blickgesteuerten Geräten funktioniert.</p>
<h2>5) Controller-Positionen</h2>
<pre><code class="language-js">function getControllerPose(frame, source) {
  if (!source.gripSpace) return null
  
  const pose = frame.getPose(source.gripSpace, xrRefSpace)
  if (!pose) return null
  
  return {
    position: pose.transform.position,
    orientation: pose.transform.orientation,
    matrix: pose.transform.matrix,
  }
}

function renderControllers(frame) {
  for (const source of xrSession.inputSources) {
    const pose = getControllerPose(frame, source)
    if (pose) {
      renderControllerModel(pose, source.handedness)
    }
  }
}
</code></pre>
<h2>6) Raycasting fürs Zeigen</h2>
<pre><code class="language-js">function getControllerRay(frame, source) {
  if (!source.targetRaySpace) return null
  
  const pose = frame.getPose(source.targetRaySpace, xrRefSpace)
  if (!pose) return null
  
  const origin = pose.transform.position
  const direction = {
    x: -pose.transform.matrix[8],
    y: -pose.transform.matrix[9],
    z: -pose.transform.matrix[10],
  }
  
  return { origin, direction }
}

function checkRayIntersection(ray, objects) {
  let closest = null
  let closestDist = Infinity
  
  for (const obj of objects) {
    const dist = rayIntersectBox(ray, obj.boundingBox)
    if (dist !== null &amp;&amp; dist &lt; closestDist) {
      closest = obj
      closestDist = dist
    }
  }
  
  return closest
}
</code></pre>
<h2>7) Fortbewegung per Teleport</h2>
<pre><code class="language-js">class TeleportSystem {
  constructor() {
    this.targetPosition = null
    this.isAiming = false
  }
  
  update(frame, inputSources) {
    for (const source of inputSources) {
      if (source.handedness !== 'left') continue
      
      const gamepad = source.gamepad
      const thumbstickY = gamepad?.axes[3] || 0
      
      if (thumbstickY &lt; -0.5) {
        // Aiming
        this.isAiming = true
        const ray = getControllerRay(frame, source)
        this.targetPosition = this.findTeleportTarget(ray)
      } else if (this.isAiming) {
        // Release - teleport
        if (this.targetPosition) {
          player.position.x = this.targetPosition.x
          player.position.z = this.targetPosition.z
        }
        this.isAiming = false
        this.targetPosition = null
      }
    }
  }
  
  findTeleportTarget(ray) {
    // Intersect with ground plane
    if (ray.direction.y &gt;= 0) return null
    
    const t = -ray.origin.y / ray.direction.y
    if (t &lt; 0 || t &gt; 10) return null
    
    return {
      x: ray.origin.x + ray.direction.x * t,
      y: 0,
      z: ray.origin.z + ray.direction.z * t,
    }
  }
  
  render() {
    if (this.isAiming &amp;&amp; this.targetPosition) {
      renderTeleportMarker(this.targetPosition)
    }
  }
}
</code></pre>
<h2>8) Flüssige Fortbewegung</h2>
<pre><code class="language-js">function updateSmoothLocomotion(frame, inputSources, dt) {
  for (const source of inputSources) {
    if (source.handedness !== 'left') continue
    
    const gamepad = source.gamepad
    if (!gamepad) continue
    
    const moveX = gamepad.axes[2] || 0
    const moveY = gamepad.axes[3] || 0
    
    // Apply deadzone
    if (Math.abs(moveX) &lt; 0.1) moveX = 0
    if (Math.abs(moveY) &lt; 0.1) moveY = 0
    
    // Get head direction for movement
    const pose = frame.getViewerPose(xrRefSpace)
    if (!pose) continue
    
    const headMatrix = pose.transform.matrix
    const forward = { x: -headMatrix[8], z: -headMatrix[10] }
    const right = { x: headMatrix[0], z: headMatrix[2] }
    
    // Normalize to XZ plane
    const len = Math.sqrt(forward.x ** 2 + forward.z ** 2)
    forward.x /= len
    forward.z /= len
    
    const speed = 3 * dt
    player.position.x += (forward.x * -moveY + right.x * moveX) * speed
    player.position.z += (forward.z * -moveY + right.z * moveX) * speed
  }
}
</code></pre>
<h2>9) Komfortregeln für VR</h2>
<pre><code class="language-js">// Vignette during movement to reduce motion sickness
function renderComfortVignette(movementSpeed) {
  const intensity = Math.min(movementSpeed / 5, 0.5)
  if (intensity &lt; 0.1) return
  
  // Render dark edges that increase with speed
  renderVignette(intensity)
}

// Snap turning
let snapTurnCooldown = 0

function handleSnapTurn(gamepad, dt) {
  snapTurnCooldown -= dt
  
  const thumbstickX = gamepad.axes[2] || 0
  
  if (Math.abs(thumbstickX) &gt; 0.7 &amp;&amp; snapTurnCooldown &lt;= 0) {
    const angle = Math.sign(thumbstickX) * (Math.PI / 4) // 45 degrees
    player.rotation += angle
    snapTurnCooldown = 0.3 // Prevent rapid turns
  }
}
</code></pre>
<h2>10) Vollständiges WebXR-Setup</h2>
<pre><code class="language-js">class VRGame {
  constructor(canvas) {
    this.canvas = canvas
    this.gl = canvas.getContext('webgl2', { xrCompatible: true })
    this.session = null
    this.refSpace = null
    this.teleport = new TeleportSystem()
  }
  
  async checkSupport() {
    if (!navigator.xr) return false
    return await navigator.xr.isSessionSupported('immersive-vr')
  }
  
  async start() {
    this.session = await navigator.xr.requestSession('immersive-vr', {
      requiredFeatures: ['local-floor'],
    })
    
    await this.session.updateRenderState({
      baseLayer: new XRWebGLLayer(this.session, this.gl),
    })
    
    this.refSpace = await this.session.requestReferenceSpace('local-floor')
    this.session.addEventListener('end', () =&gt; this.onEnd())
    this.session.requestAnimationFrame((t, f) =&gt; this.onFrame(t, f))
  }
  
  onFrame(time, frame) {
    this.session.requestAnimationFrame((t, f) =&gt; this.onFrame(t, f))
    
    const dt = (time - this.lastTime) / 1000
    this.lastTime = time
    
    // Update game logic
    this.teleport.update(frame, this.session.inputSources)
    
    // Render
    const pose = frame.getViewerPose(this.refSpace)
    if (!pose) return
    
    const glLayer = this.session.renderState.baseLayer
    this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, glLayer.framebuffer)
    this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT)
    
    for (const view of pose.views) {
      const vp = glLayer.getViewport(view)
      this.gl.viewport(vp.x, vp.y, vp.width, vp.height)
      this.render(view.transform.inverse.matrix, view.projectionMatrix)
    }
    
    this.teleport.render()
  }
  
  render(viewMatrix, projectionMatrix) {
    // Your rendering code here
  }
  
  onEnd() {
    this.session = null
  }
}
</code></pre>
<h2>Verwandt</h2>
<ul>
<li><a href="/de/tutorials/webgl-fundamentals-for-games">WebGL-Grundlagen</a></li>
<li><a href="/de/tutorials/webgpu-getting-started">WebGPU-Einstieg</a></li>
<li><a href="/de/tutorials/game-input-handling">Eingaben in Spielen</a></li>
<li><a href="/de/guides/web-games-stack-2026">Tech-Stack für Web-Spiele 2026</a> — wo WebXR in der Werkzeuglandschaft sitzt</li>
<li><a href="/de/guides/threejs-usdc-tech-report">Three.js und USDC im Browser</a> — 3D-Assets für XR-Szenen laden</li>
</ul>
<h2>Externe Ressourcen</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API">MDN: WebXR Device API</a> — vollständige API-Referenz</li>
<li><a href="https://immersiveweb.dev/">Immersive Web</a> — WebXR-Beispiele, Werkzeuge und Tabellen zur Browser-Unterstützung</li>
<li><a href="https://threejs.org/docs/#manual/en/introduction/How-to-create-VR-content">Three.js WebXR guide</a> — VR-Inhalte mit Three.js</li>
</ul>
]]></content:encoded>
        </item>
    </channel>
</rss>
