<?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 Tutoriels]]></title>
        <link>https://app.cinevva.com</link>
        <description><![CDATA[Des tutoriels concrets pour créer des jeux web.]]></description>
        <lastBuildDate>Sun, 16 Aug 2026 04:34:04 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[Outils de code d’IA agentique]]></title>
            <link>https://app.cinevva.com/fr/tutorials/agentic-code-tools</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/agentic-code-tools</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Maîtrisez Claude Code, Cursor et GitHub Copilot : comprenez les flux de travail agentiques, les bonnes pratiques et quel outil utiliser pour maximiser votre productivité.]]></description>
            <content:encoded><![CDATA[<h1>Outils de code d’IA agentique</h1>
<p>Les assistants de programmation par IA ont évolué : de simples outils d’autocomplétion, ils sont devenus des agents autonomes capables de planifier, de modifier plusieurs fichiers, d’exécuter des tests et de créer des pull requests. Ce tutoriel présente les trois principaux outils : Claude Code, Cursor et GitHub Copilot.</p>
<h2>1) Qu’est-ce qui rend un outil « agentique » ?</h2>
<p>Les outils traditionnels de complétion de code par IA suggèrent les quelques lignes suivantes. Les outils agentiques vont plus loin :</p>
<table>
<thead>
<tr>
<th>Capacité</th>
<th>Autocomplétion</th>
<th>Agentique</th>
</tr>
</thead>
<tbody>
<tr>
<td>Portée</td>
<td>Ligne ou fonction actuelle</td>
<td>Ensemble de la base de code</td>
</tr>
<tr>
<td>Planification</td>
<td>Aucune</td>
<td>Raisonnement en plusieurs étapes</td>
</tr>
<tr>
<td>Exécution</td>
<td>Suggestions uniquement</td>
<td>Peut modifier des fichiers et exécuter des commandes</td>
</tr>
<tr>
<td>Contexte</td>
<td>Fichier actuel</td>
<td>Compréhension de l’ensemble du dépôt</td>
</tr>
<tr>
<td>Flux de travail</td>
<td>Réactif</td>
<td>Exécution autonome des tâches</td>
</tr>
</tbody>
</table>
<p>Les outils agentiques peuvent comprendre toute la structure de votre projet, planifier des modifications dans plusieurs fichiers, les appliquer, exécuter des tests et itérer jusqu’à ce que la tâche soit terminée.</p>
<h2>2) Comparaison rapide : Claude Code, Cursor et Copilot</h2>
<table>
<thead>
<tr>
<th>Fonctionnalité</th>
<th>Claude Code</th>
<th>Cursor</th>
<th>GitHub Copilot</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Interface</strong></td>
<td>CLI, web, extensions d’IDE</td>
<td>IDE complet (fork de VS Code)</td>
<td>Extensions d’IDE</td>
</tr>
<tr>
<td><strong>Fenêtre de contexte</strong></td>
<td>Grande (Opus à contexte long)</td>
<td>Indexation de tout le dépôt</td>
<td>Plus petite, en amélioration</td>
</tr>
<tr>
<td><strong>Modifications multifichiers</strong></td>
<td>Performant</td>
<td>Excellent</td>
<td>En amélioration</td>
</tr>
<tr>
<td><strong>Mode Plan</strong></td>
<td>Oui</td>
<td>Oui</td>
<td>Oui (plus récent)</td>
</tr>
<tr>
<td><strong>Tarification</strong></td>
<td>Formules Pro/Max</td>
<td>Crédits + abonnement</td>
<td>Abonnement</td>
</tr>
<tr>
<td><strong>Idéal pour</strong></td>
<td>Architecture, refactorisations importantes</td>
<td>Développement interactif</td>
<td>Programmation en ligne</td>
</tr>
</tbody>
</table>
<h2>3) Principes fondamentaux de Claude Code</h2>
<p>Claude Code fonctionne avec Opus 4.8 (son modèle par défaut actuel) et de grandes fenêtres de contexte. Installez-le et authentifiez-vous :</p>
<pre><code class="language-bash"># Installer Claude Code (programme d’installation natif recommandé)
curl -fsSL https://claude.ai/install.sh | bash

# Ou avec npm (nécessite Node 18+)
npm install -g @anthropic-ai/claude-code

# S’authentifier : exécutez claude et suivez l’invite dans le navigateur
claude

# Démarrer dans votre projet
cd your-project
claude
</code></pre>
<h3>Commandes de base</h3>
<pre><code class="language-bash"># Poser une question sur votre base de code
claude &quot;Comment l’authentification est-elle implémentée ici ?&quot;

# Générer du code
claude &quot;Ajoute un middleware de limitation de débit&quot;

# Refactorisation multifichier
claude &quot;Renomme UserService en AuthService dans toute la base de code&quot;

# Créer une pull request
claude &quot;Crée une pull request qui ajoute la validation des entrées à tous les endpoints d’API&quot;
</code></pre>
<h3>Configuration du projet</h3>
<p>Placez le contexte du projet dans un fichier <code>CLAUDE.md</code> à la racine de votre dépôt. Claude Code le lit automatiquement au début de chaque session :</p>
<pre><code class="language-markdown"># Notes du projet

- Langage : TypeScript, formaté avec Prettier
- Tests : Vitest, dans tests/
- Ne pas modifier node_modules/ ni dist/
</code></pre>
<p>Les paramètres lisibles par machine (autorisations des outils, variables d’environnement, hooks) se placent dans <code>.claude/settings.json</code>. Par exemple, pour exiger une approbation avant l’exécution d’outils destructifs, configurez-y les autorisations.</p>
<h3>Mémoire et contexte</h3>
<p>Le contexte persistant du projet se trouve dans les fichiers <a href="http://CLAUDE.md">CLAUDE.md</a>, que Claude Code charge au début de chaque session, ainsi que dans une mémoire automatique que Claude met lui-même à jour en fonction de vos corrections et préférences. Pour lui fournir un contexte durable, modifiez <a href="http://CLAUDE.md">CLAUDE.md</a> plutôt que d’envoyer une instruction ponctuelle commençant par « Souviens-toi : » :</p>
<pre><code class="language-markdown"># CLAUDE.md
Ceci est un monorepo. Le code partagé se trouve dans packages/.
</code></pre>
<p>Utilisez la commande <code>/clear</code> au cours d’une session pour réinitialiser le contexte de travail.</p>
<h2>4) Fonctionnalités agentiques de l’IDE Cursor</h2>
<p>Cursor est conçu autour de flux de travail agentiques. Voici ses principales fonctionnalités :</p>
<h3>Mode Agent</h3>
<p>Appuyez sur <code>Cmd+Shift+I</code> (Mac) ou <code>Ctrl+Shift+I</code> (Windows/Linux) pour ouvrir Composer en mode Agent :</p>
<pre><code>Flux de travail du mode Agent :
1. Décrivez votre tâche
2. L’agent crée un plan
3. Vous l’approuvez ou le modifiez
4. L’agent applique les modifications
5. Vérifiez et itérez
</code></pre>
<h3>Mode Plan</h3>
<p>Pour les tâches complexes, utilisez explicitement le mode Plan :</p>
<pre><code>@plan Ajoute une authentification utilisateur avec :
- Des jetons JWT
- La rotation des jetons d’actualisation
- Le hachage des mots de passe avec bcrypt
- La limitation des tentatives de connexion
</code></pre>
<p>L’agent va :</p>
<ol>
<li>Étudier votre code existant</li>
<li>Créer un plan d’implémentation détaillé</li>
<li>Attendre votre approbation</li>
<li>L’exécuter étape par étape</li>
</ol>
<h3>Injection de contexte</h3>
<p>Utilisez <code>@</code> pour ajouter un contexte précis :</p>
<pre><code>@file src/api/auth.ts
@folder src/middleware
@docs https://jwt.io/introduction

Implémente un middleware de vérification JWT en suivant ces modèles
</code></pre>
<h3>Agents parallèles</h3>
<p>Exécutez plusieurs agents simultanément sur différentes branches :</p>
<pre><code># Dans Cursor :
1. Ouvrez la palette de commandes
2. &quot;Cursor: New Agent in Worktree&quot;
3. Chaque agent travaille de façon isolée
4. Comparez les résultats côte à côte
</code></pre>
<p>Cursor 3 a ajouté une fenêtre Agents (ouvrez-la avec Cmd+Shift+P, puis « Agents Window ») qui exécute plusieurs agents en parallèle dans des copies de travail locales, des worktrees git et des environnements cloud. La commande /worktree place chaque agent dans son propre worktree git isolé, tandis que /best-of-n exécute la même tâche avec plusieurs modèles afin que vous puissiez comparer les résultats côte à côte.</p>
<h3>Règles de cohérence</h3>
<p>Créez <code>.cursor/rules/project.mdc</code> :</p>
<pre><code class="language-markdown"># Règles du projet

## Style du code
- Utiliser le mode strict de TypeScript
- Privilégier les composants fonctionnels
- Utiliser des exports nommés, pas d’export par défaut

## Tests
- Chaque fonctionnalité doit avoir des tests
- Utiliser des blocs describe/it
- Simuler les services externes

## Sécurité
- Ne jamais committer de secrets
- Valider toutes les entrées utilisateur
- Utiliser des requêtes paramétrées
</code></pre>
<h2>5) Fonctionnalités agentiques de GitHub Copilot</h2>
<p>Copilot a évolué au-delà de l’autocomplétion :</p>
<h3>Copilot Chat</h3>
<p>Ouvrez le panneau de discussion et utilisez les commandes slash :</p>
<pre><code>/explain - Expliquer le code sélectionné
/fix - Corriger les problèmes de la sélection
/tests - Générer des tests
/doc - Ajouter de la documentation
</code></pre>
<h3>Agent de programmation (d’une issue à une pull request)</h3>
<pre><code># Sur GitHub :
1. Ouvrez une issue GitHub
2. Attribuez-la à &quot;Copilot&quot;
3. L’agent de programmation travaille en arrière-plan :
   il analyse l’issue, modifie les fichiers et exécute les tests
4. Il ouvre une pull request afin que vous puissiez la vérifier
</code></pre>
<p>Cela se distingue du mode Agent de Copilot dans l’IDE, qui travaille de manière synchrone à vos côtés dans VS Code ou JetBrains.</p>
<p>Copilot répartit désormais son travail autonome en deux modes : le mode Agent s’exécute de manière synchrone dans votre IDE (VS Code et JetBrains), choisit les fichiers à modifier, exécute des commandes dans le terminal et itère sur les erreurs ; l’agent de programmation s’exécute de manière asynchrone dans le cloud, prend en charge une issue qui lui est attribuée et fournit une pull request pendant que vous travaillez sur autre chose.</p>
<h3>Suggestions en ligne avec contexte</h3>
<p>Copilot lit votre base de code. Aidez-le avec des commentaires :</p>
<pre><code class="language-typescript">// Middleware d’authentification qui :
// - Extrait le JWT de l’en-tête Authorization
// - Valide la signature du jeton
// - Associe l’utilisateur à la requête
// - Renvoie 401 si le jeton est invalide
export function authMiddleware(req, res, next) {
  // Copilot génère l’implémentation
}
</code></pre>
<h2>6) Bonnes pratiques pour tous les outils</h2>
<h3>Rédigez des prompts clairs</h3>
<pre><code>❌ Mauvais : &quot;Corrige le bug&quot;

✅ Bon : &quot;Corrige l’exception de pointeur nul dans UserService.getById
lorsque l’utilisateur n’existe pas. Renvoie null au lieu de lever une exception.&quot;

❌ Mauvais : &quot;Ajoute des tests&quot;

✅ Bon : &quot;Ajoute des tests unitaires pour CartService couvrant :
- L’ajout d’articles à un panier vide
- La suppression d’articles
- Le calcul des totaux avec des remises
- Cas limite : quantités négatives&quot;
</code></pre>
<h3>Utilisez le mode Plan pour les tâches complexes</h3>
<pre><code>Pour toute tâche touchant au moins 3 fichiers, toujours :

1. Demander d’abord un plan
2. Examiner le plan
3. L’approuver ou le modifier
4. Laisser l’agent l’exécuter
5. Vérifier les modifications avant de committer
</code></pre>
<h3>Fournissez le contexte architectural</h3>
<pre><code>Avant tout travail important, présentez l’architecture à l’agent :

&quot;Il s’agit d’une architecture de microservices :
- api-gateway : achemine les requêtes
- auth-service : JWT, sessions
- user-service : CRUD des utilisateurs
- notification-service : e-mails, notifications push

Les services communiquent via le système pub/sub de Redis.
La base de données est PostgreSQL avec l’ORM Prisma.&quot;
</code></pre>
<h3>Développement piloté par les tests avec des agents</h3>
<pre><code>1. Écrire d’abord un test qui échoue :
   &quot;Écris un test d’inscription utilisateur qui vérifie la validation de l’adresse e-mail&quot;

2. Laisser l’agent voir le test qui échoue :
   &quot;Implémente l’inscription pour faire réussir ce test&quot;

3. Itérer jusqu’à ce que tous les tests réussissent :
   &quot;Corrige les derniers échecs de tests&quot;
</code></pre>
<h2>7) Considérations de sécurité</h2>
<p>Les outils agentiques disposent de véritables pouvoirs. Définissez des limites :</p>
<h3>Étapes d’approbation</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>Environnements isolés</h3>
<pre><code class="language-bash"># Exécuter les agents dans des environnements isolés
docker run -v $(pwd):/workspace -it claude-sandbox

# Ou utiliser des worktrees git pour les isoler
git worktree add ../feature-branch feature-branch
</code></pre>
<h3>Fichiers sensibles</h3>
<pre><code># .cursorignore / .claudeignore
.env
.env.*
secrets/
credentials/
*.pem
*.key
</code></pre>
<h3>Vérifiez toutes les modifications</h3>
<pre><code class="language-bash"># Avant d’accepter les modifications de l’agent :
git diff --stat
git diff

# Utiliser l’ajout interactif à l’index
git add -p

# Ne jamais fusionner automatiquement sans vérification
</code></pre>
<h2>8) Modèles de flux de travail efficaces</h2>
<h3>Modèle 1 : Explorer, planifier, exécuter</h3>
<pre><code>Session 1 : &quot;Comment fonctionne le système de paiement ?&quot;
            → L’agent explore, vous apprenez

Session 2 : &quot;Que faudrait-il faire pour ajouter Stripe ?&quot;
            → L’agent crée un plan

Session 3 : &quot;Implémente l’intégration de Stripe&quot;
            → L’agent exécute le plan
</code></pre>
<h3>Modèle 2 : Exploration parallèle</h3>
<pre><code># Dans Cursor avec des agents parallèles :

Agent 1 : &quot;Implémente la mise en cache avec Redis&quot;
Agent 2 : &quot;Implémente la mise en cache avec Memcached&quot;
Agent 3 : &quot;Implémente la mise en cache avec un cache LRU en mémoire&quot;

→ Comparez les implémentations et choisissez la meilleure approche
</code></pre>
<h3>Modèle 3 : Migration progressive</h3>
<pre><code>Étape 1 : &quot;Identifie toutes les utilisations de l’API obsolète&quot;
Étape 2 : &quot;Crée un adaptateur pour la nouvelle API&quot;
Étape 3 : &quot;Migre le module A pour utiliser l’adaptateur&quot;
Étape 4 : &quot;Migre le module B...&quot;
...
Étape N : &quot;Supprime l’adaptateur et utilise directement la nouvelle API&quot;
</code></pre>
<h3>Modèle 4 : Développement de fonctionnalités guidé par les tests</h3>
<pre><code>1. &quot;Écris des tests d’acceptation pour la modification du profil utilisateur&quot;
2. &quot;Écris des tests unitaires pour la validation du profil&quot;
3. &quot;Implémente la fonctionnalité pour faire réussir les tests&quot;
4. &quot;Ajoute des tests d’intégration&quot;
5. &quot;Refactorise pour améliorer la clarté&quot;
</code></pre>
<h2>9) Quel outil utiliser selon la situation</h2>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Outil recommandé</th>
</tr>
</thead>
<tbody>
<tr>
<td>Programmation en temps réel, aide en ligne</td>
<td>GitHub Copilot</td>
</tr>
<tr>
<td>Refactorisations importantes, architecture</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Session de développement complète</td>
<td>Cursor</td>
</tr>
<tr>
<td>Questions rapides sur le code</td>
<td>N’importe quelle interface de discussion</td>
</tr>
<tr>
<td>Création de pull requests à partir d’issues</td>
<td>Copilot Workspace</td>
</tr>
<tr>
<td>Modifications complexes sur plusieurs fichiers</td>
<td>Cursor Agent</td>
</tr>
<tr>
<td>Conception d’API, documentation</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Découverte d’une nouvelle base de code</td>
<td>Cursor ou Claude Code</td>
</tr>
</tbody>
</table>
<h2>10) Mesurer l’efficacité</h2>
<p>Suivez ces indicateurs pour évaluer les outils agentiques :</p>
<pre><code class="language-javascript">const metrics = {
  // Productivité
  suggestionsAccepted: 'pourcentage de suggestions conservées',
  timeToFeature: 'nombre d’heures entre le début et la fusion de la pull request',
  linesPerHour: 'vélocité du code (à utiliser avec prudence)',
  
  // Qualité
  bugsIntroduced: 'défauts attribués au code généré par l’IA',
  testCoverage: 'couverture du code généré par l’IA',
  reviewChanges: 'ampleur de vos modifications des suggestions de l’IA',
  
  // Coût
  apiCosts: 'dépenses mensuelles en outils d’IA',
  creditsUsed: 'crédits Cursor consommés',
  
  // Apprentissage
  newPatternsLearned: 'techniques apprises grâce à l’IA',
  codebaseUnderstanding: 'mesure dans laquelle l’IA vous a aidé à apprendre',
}
</code></pre>
<h2>11) Pièges fréquents</h2>
<h3>Accorder une confiance excessive à l’agent</h3>
<pre><code>❌ Accepter des modifications importantes sans vérification
❌ Laisser les agents exécuter des commandes destructives
❌ Ne pas comprendre le code avant de le mettre en production

✅ Vérifier chaque modification
✅ Comprendre ce que vous livrez
✅ Utiliser les agents comme collaborateurs, pas comme remplaçants
</code></pre>
<h3>Pollution du contexte</h3>
<pre><code>❌ Prompts énormes contenant du contexte non pertinent
❌ Ne pas effacer le contexte entre des tâches sans rapport
❌ Inclure les fichiers générés dans le contexte

✅ Garder les prompts ciblés
✅ Utiliser des références @file précises
✅ Exclure les artefacts de compilation
</code></pre>
<h3>Ambiguïté des prompts</h3>
<pre><code>❌ &quot;Améliore-le&quot;
❌ &quot;Corrige les problèmes&quot;
❌ &quot;Ajoute quelques tests&quot;

✅ &quot;Réduis la complexité temporelle de O(n²) à O(n log n)&quot;
✅ &quot;Gère le cas null à la ligne 45 avec un retour anticipé&quot;
✅ &quot;Ajoute des tests pour les cas limites : entrée vide, longueur maximale, Unicode&quot;
</code></pre>
<h2>12) Liste de vérification pour démarrer</h2>
<pre><code class="language-markdown">## Liste de vérification de la configuration

### Claude Code
- [ ] Installer la CLI : `npm install -g @anthropic/claude-code`
- [ ] S’authentifier : `claude login`
- [ ] Créer `.claude/config.json`
- [ ] Ajouter `.claudeignore` pour les fichiers sensibles

### Cursor
- [ ] Télécharger depuis cursor.com
- [ ] Importer les paramètres de VS Code
- [ ] Configurer `.cursor/rules/`
- [ ] Apprendre le raccourci `Cmd+Shift+I` pour le mode Agent

### GitHub Copilot
- [ ] Installer l’extension dans votre IDE
- [ ] Activer Copilot Chat
- [ ] Essayer Copilot Workspace pour les issues
- [ ] Configurer `.github/copilot-instructions.md`

### Tous les outils
- [ ] Configurer des étapes d’approbation pour les opérations destructives
- [ ] Créer des fichiers d’exclusion pour les secrets
- [ ] Établir un flux de vérification
- [ ] Suivre les indicateurs dès le premier jour
</code></pre>
<h2>À découvrir aussi</h2>
<ul>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/web-workers-game-logic">Web Workers pour la logique de jeu</a></li>
<li><a href="/fr/guides/game-jams-hackathons">Game jams et hackathons</a> — les outils de code par IA transforment ce qu’il est possible d’accomplir pendant une game jam de 48 heures</li>
<li><a href="/fr/guides/web-game-engines-comparison">Comparatif des moteurs de jeux web</a> — choisir un moteur adapté aux flux de travail assistés par l’IA</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Analyses et télémétrie pour les jeux web]]></title>
            <link>https://app.cinevva.com/fr/tutorials/analytics-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/analytics-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Suivez ce qui compte dans votre jeu : comportement des joueurs, performances, erreurs et conversions. Prenez des décisions fondées sur les données.]]></description>
            <content:encoded><![CDATA[<h1>Analyses et télémétrie pour les jeux web</h1>
<p>Les analyses vous aident à comprendre ce que font les joueurs, où ils rencontrent des difficultés et ce qui maintient leur intérêt. Ce tutoriel présente les données à suivre et la manière de les collecter.</p>
<h2>1) Données à suivre</h2>
<p><strong>Indicateurs d’engagement :</strong></p>
<ul>
<li>Débuts et durée des sessions</li>
<li>Niveaux commencés et terminés</li>
<li>Fonctionnalités utilisées</li>
<li>Rétention (visites ultérieures)</li>
</ul>
<p><strong>Indicateurs de performance :</strong></p>
<ul>
<li>Temps de chargement</li>
<li>Fréquence d’images</li>
<li>Utilisation de la mémoire</li>
<li>Erreurs et plantages</li>
</ul>
<p><strong>Indicateurs de conversion :</strong></p>
<ul>
<li>Fin du tutoriel</li>
<li>Premier achat</li>
<li>Partages sur les réseaux sociaux</li>
</ul>
<h2>2) Suivi simple des événements</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) Suivi des sessions</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) Événements propres au jeu</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) Suivi des performances</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>Attribuer les saccades avec Long Animation Frames</h3>
<p>Le compteur de FPS ci-dessus vous indique <em>quand</em> des images sont perdues, mais pas <em>pourquoi</em>. L’API Long Animation Frames (LoAF), intégrée à Chrome et Edge 123, comble cette lacune. Elle signale toute image dont le rendu prend plus de 50 ms et détaille les scripts à l’origine du retard, afin que vous puissiez attribuer les saccades à du code précis au lieu de procéder par supposition.</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>Pour l’instant, LoAF n’est disponible que dans Chromium, et non dans Firefox ou Safari. Vérifiez donc la disponibilité de la fonctionnalité avant de l’utiliser et conservez le compteur de FPS comme référence compatible avec tous les navigateurs.</p>
<h2>6) Suivi des erreurs</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) Suivi du temps de chargement</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) Suivi des entonnoirs</h2>
<p>Suivez la progression des joueurs dans les parcours clés :</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) Prise en charge des tests A/B</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) Considérations relatives à la confidentialité</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;Nous utilisons des outils d’analyse pour améliorer le jeu. Êtes-vous d’accord ?&lt;/p&gt;
    &lt;button id=&quot;accept&quot;&gt;Accepter&lt;/button&gt;
    &lt;button id=&quot;decline&quot;&gt;Refuser&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>Solutions tierces</h2>
<p>Si vous ne souhaitez pas créer votre propre système :</p>
<ul>
<li><strong>Plausible</strong> — Simple et axé sur la confidentialité</li>
<li><strong>Amplitude</strong> — Analyse produit et entonnoirs</li>
<li><strong>Mixpanel</strong> — Suivi des événements et parcours utilisateurs</li>
<li><strong>Sentry</strong> — Spécialisé dans le suivi des erreurs</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>À consulter également</h2>
<ul>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/indexeddb-game-saves">Sauvegardes de jeu avec IndexedDB</a></li>
<li><a href="/fr/creators">Pour les créateurs</a></li>
<li><a href="/fr/guides/itch-io-launch-guide">Comment lancer votre jeu sur itch.io</a> — analyses <a href="http://itch.io">itch.io</a> et suivi des téléchargements</li>
<li><a href="/fr/guides/steam-next-fest-strategy">Stratégie pour le Steam Next Fest</a> — mesure des ajouts aux listes de souhaits et des taux de conversion de la démo</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://plausible.io/">Plausible Analytics</a> — solution d’analyse légère et respectueuse de la vie privée</li>
<li><a href="https://posthog.com/">PostHog</a> — solution d’analyse produit open source avec suivi des événements</li>
<li><a href="https://sentry.io/">Sentry</a> — suivi des erreurs et rapports de plantage pour les applications web</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Principes fondamentaux de la boucle de jeu Canvas 2D]]></title>
            <link>https://app.cinevva.com/fr/tutorials/canvas-2d-game-loop</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/canvas-2d-game-loop</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Créez une boucle de jeu robuste avec Canvas 2D. Découvrez la gestion du temps, le pas de temps fixe, les entrées, le rendu et les erreurs courantes qui posent problème.]]></description>
            <content:encoded><![CDATA[<h1>Principes fondamentaux de la boucle de jeu Canvas 2D</h1>
<p>La boucle de jeu est le cœur de votre jeu. Elle s'exécute en continu, met à jour l'état du jeu et dessine les images aussi rapidement que le navigateur le permet. Si vous la concevez correctement, tout le reste devient plus simple. Dans le cas contraire, vous chercherez des bugs qui n'apparaissent que sur certaines machines.</p>
<p><strong>Essayez (utilisez les touches fléchées ou ZQSD/WASD) :</strong></p>
<GameLoopDemo />
<h2>La version la plus simple</h2>
<p>Voici une boucle de jeu réduite à l'essentiel :</p>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

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

function update() {
  // Logique du jeu ici
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Dessin ici
}

gameLoop()
</code></pre>
<p>Cela fonctionne, mais il y a un problème. La fonction <code>update</code> ne sait pas combien de temps s'est écoulé depuis l'image précédente. Sur un écran à 144 Hz, elle s'exécute deux fois plus vite que sur un écran à 60 Hz. La physique de votre jeu se comportera donc différemment selon les machines.</p>
<h2>Le delta time corrige les problèmes de fréquence d'images</h2>
<p>Pour que votre jeu s'exécute partout à la même vitesse, vous devez mesurer le temps écoulé entre deux images. C'est ce qu'on appelle le delta time, généralement abrégé en <code>dt</code>.</p>
<pre><code class="language-js">let lastTime = 0

function gameLoop(currentTime) {
  const dt = (currentTime - lastTime) / 1000 // convertir en secondes
  lastTime = currentTime
  
  update(dt)
  render()
  
  requestAnimationFrame(gameLoop)
}

function update(dt) {
  player.x += player.speed * dt // fonctionne désormais à toute fréquence d'images
}

requestAnimationFrame(gameLoop)
</code></pre>
<p>La fonction de rappel de <code>requestAnimationFrame</code> reçoit l'horodatage actuel en millisecondes. Je le divise par 1 000 pour obtenir des secondes, car cela simplifie les calculs. Une vitesse de 200 signifie 200 pixels par seconde, ce qui est intuitif.</p>
<p>Attention toutefois : lors de la première image, <code>lastTime</code> vaut 0, donc <code>dt</code> sera énorme. Certaines personnes initialisent <code>lastTime</code> avec <code>performance.now()</code> avant de démarrer la boucle, tandis que d'autres ignorent la mise à jour de la première image.</p>
<h2>Pourquoi requestAnimationFrame plutôt que setInterval</h2>
<p>Vous vous demandez peut-être pourquoi nous utilisons <code>requestAnimationFrame</code> plutôt que <code>setInterval</code>. Il y a plusieurs raisons.</p>
<p>Tout d'abord, <code>requestAnimationFrame</code> se synchronise avec la fréquence de rafraîchissement de l'écran, ce qui produit une animation fluide sans déchirement. Ensuite, il se met automatiquement en pause lorsque l'onglet est masqué, ce qui économise la batterie et le processeur. Enfin, le navigateur peut mieux l'optimiser.</p>
<p>Ce comportement de mise en pause est généralement souhaitable, mais il signifie que votre jeu se fige lorsque l'utilisateur change d'onglet. Si vous créez un jeu multijoueur ou quelque chose qui doit continuer à fonctionner, vous devrez adopter une autre approche.</p>
<h2>Un pas de temps fixe pour la physique</h2>
<p>Un delta time variable convient aux déplacements simples, mais pose des problèmes dans les simulations physiques. Si le calcul d'une image prend trop de temps, votre personnage risque de traverser un mur parce que la détection de collision n'a pas capté le moment de l'impact.</p>
<p>La solution consiste à utiliser un pas de temps fixe. Vous accumulez le temps et exécutez les mises à jour physiques à une fréquence fixe, quelle que soit la vitesse de rendu des images.</p>
<pre><code class="language-js">const FIXED_DT = 1 / 60 // 60 mises à jour physiques par seconde
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>Ce <code>Math.min(..., 0.25)</code> est important. Si l'onglet était masqué puis redevient actif, <code>frameTime</code> pourrait être énorme. Sans cette limite, la boucle <code>while</code> exécuterait des centaines de mises à jour pour tenter de rattraper le retard, ce qui figerait le jeu. Une limite de 0,25 seconde garantit que vous n'exécuterez jamais plus de 15 mises à jour physiques par image.</p>
<h2>Organiser l'état du jeu</h2>
<p>À mesure que votre jeu grandit, vous devez disposer d'un emplacement pour stocker toutes les données. J'aime utiliser un objet unique qui contient tout :</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>Cela facilite la sauvegarde et le chargement de l'état du jeu, sa réinitialisation lorsque le joueur meurt, ainsi que le débogage en affichant <code>game</code> dans la console. Certaines personnes préfèrent les classes, mais un simple objet convient parfaitement aux jeux de petite ou moyenne taille.</p>
<h2>Tout assembler</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
  
  // Maintenir le joueur dans les limites
  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)
}

// Gestion des entrées
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>Il s'agit d'une boucle de jeu complète et exécutable. Le joueur se déplace avec les touches fléchées ou ZQSD/WASD, reste dans les limites et se déplace à une vitesse constante, quelle que soit la fréquence d'images. Vous pouvez copier ce code comme point de départ pour n'importe quel jeu Canvas 2D.</p>
<p>Remarquez que la gestion des entrées est séparée de la boucle de jeu. Les écouteurs d'événements se contentent de définir des indicateurs, puis la fonction de mise à jour lit ces indicateurs. Ce découplage est important, car les événements du clavier se déclenchent à des moments imprévisibles, tandis que la logique du jeu doit s'exécuter à une fréquence prévisible.</p>
<h2>Charger et dessiner des sprites</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>Les images se chargent de manière asynchrone. Vous devez donc attendre qu'elles soient prêtes avant de démarrer votre jeu. Une approche courante consiste à charger tous les sprites dans une fonction <code>loadAssets</code> qui renvoie une Promise, puis à démarrer la boucle de jeu uniquement lorsque celle-ci est résolue.</p>
<p>La fonction <code>drawSprite</code> vérifie que le sprite existe avant de le dessiner. Cela évite les erreurs si vous tentez de dessiner un élément qui n'est pas encore chargé.</p>
<h2>Sprites animés</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>Cette classe suppose que les images de votre feuille de sprites sont disposées horizontalement sur une seule ligne. Chaque image a la même taille. La méthode <code>update</code> passe d'une image à la suivante en fonction du temps écoulé, tandis que <code>draw</code> utilise la version à neuf arguments de <code>drawImage</code> pour ne découper que l'image actuelle.</p>
<p>Vous l'utiliseriez comme ceci :</p>
<pre><code class="language-js">const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// Dans update :
playerWalk.update(dt)

// Dans render :
playerWalk.draw(ctx, player.x, player.y)
</code></pre>
<h2>Détection des collisions</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
}

// Dans update :
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}
</code></pre>
<p>La collision entre rectangles, également appelée AABB pour « axis-aligned bounding box », est l'option la plus simple et la plus rapide. Utilisez-la dans la plupart des cas. La collision entre cercles convient mieux aux objets ronds ou lorsque vous souhaitez des collisions plus indulgentes.</p>
<p>Pour un petit nombre d'entités, vous pouvez sans problème vérifier chaque paire. Si vous avez des centaines d'entités, vous devrez utiliser un partitionnement spatial, comme des arbres quaternaires ou une grille, mais ce sera le sujet d'un autre tutoriel.</p>
<h2>Une caméra simple</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)
  
  // Dessiner le monde
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // Dessiner l'interface utilisateur (non affectée par la caméra)
  drawUI()
}
</code></pre>
<p>L'astuce repose sur <code>ctx.save()</code> et <code>ctx.restore()</code>. Vous enregistrez l'état actuel de la transformation, appliquez le décalage de la caméra avec <code>translate</code>, dessinez tous les éléments dans l'espace du monde, puis restaurez l'état afin que la caméra n'affecte pas les éléments de l'interface utilisateur.</p>
<p>Pour obtenir un déplacement de caméra plus fluide, vous pouvez effectuer une interpolation linéaire vers la cible plutôt que de vous y placer instantanément. Une expression comme <code>this.x += (targetX - this.x) * 0.1</code> produit un agréable effet de suivi avec retard.</p>
<h2>Mettre le jeu en pause</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('PAUSE', canvas.width / 2, canvas.height / 2)
}
</code></pre>
<p>L'idée essentielle est d'arrêter d'accumuler le temps lorsque le jeu est en pause, tout en continuant le rendu. Ainsi, la surimpression de pause apparaît immédiatement et le jeu reste réactif à la commande permettant de reprendre la partie.</p>
<p>Vous pouvez étendre cette approche pour gérer plusieurs états du jeu, comme les menus, les cinématiques et les écrans de fin de partie. Il suffit de vérifier l'état actuel et de rediriger l'exécution vers différentes fonctions de mise à jour et de rendu.</p>
<h2>Erreurs courantes</h2>
<p>Voici quelques difficultés que j'ai souvent vu poser problème :</p>
<p><strong>Oublier d'effacer le canevas.</strong> Si vous n'appelez pas <code>clearRect</code> au début du rendu, vous dessinerez par-dessus l'image précédente et obtiendrez un effet de traînée.</p>
<p><strong>Ne pas limiter le delta time.</strong> Si quelqu'un change d'onglet puis revient, un delta time énorme peut perturber votre physique ou obliger le jeu à « rattraper » plusieurs secondes de retard.</p>
<p><strong>Dessiner avant le chargement des images.</strong> Canvas ignore silencieusement les appels à <code>drawImage</code> utilisant des images qui ne sont pas encore chargées. Vos sprites n'apparaîtront tout simplement pas, sans aucun message d'erreur.</p>
<p><strong>Utiliser <code>setInterval</code> pour la boucle.</strong> Cette fonction ne se synchronise pas avec l'écran et ne se met pas en pause lorsque l'onglet est masqué. Utilisez <code>requestAnimationFrame</code>.</p>
<p><strong>Mélanger les coordonnées du monde et de l'écran.</strong> Lorsque vous ajoutez une caméra, veillez à savoir si une position se trouve dans l'espace du monde ou dans celui de l'écran. Les clics de souris utilisent les coordonnées de l'écran et doivent être convertis.</p>
<h2>Pour aller plus loin</h2>
<p>Ce tutoriel couvre les principes fondamentaux. Une fois ces approches maîtrisées, vous pourrez explorer les sujets suivants :</p>
<p><a href="/fr/tutorials/responsive-game-canvas">Canvas de jeu adaptatif</a> pour gérer différentes tailles d'écran et les affichages à haute densité de pixels.</p>
<p><a href="/fr/tutorials/game-input-handling">Gestion des entrées de jeu</a> pour les commandes tactiles, les manettes et les systèmes d'entrée plus avancés.</p>
<p><a href="/fr/tutorials/pixel-art-rendering">Rendu du pixel art</a> pour obtenir des graphismes en pixel art nets et sans flou.</p>
<p><a href="/fr/tutorials/game-physics-libraries">Bibliothèques de physique pour les jeux</a> pour ajouter la physique des corps rigides, des collisions et des articulations à votre boucle de jeu.</p>
<p>Si vous hésitez entre Canvas 2D, WebGL et WebGPU pour votre projet, le guide <a href="/fr/guides/web-games-stack-2026">Technologies des jeux web en 2026</a> présente leurs avantages et leurs inconvénients. Le <a href="/fr/guides/web-game-engines-comparison">Comparatif des moteurs de jeux web</a> couvre les frameworks qui gèrent la boucle de jeu à votre place.</p>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame">MDN : requestAnimationFrame</a> — référence de l’API du navigateur</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API">MDN : API Canvas</a> — documentation complète de Canvas 2D</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent">MDN : KeyboardEvent</a> — codes des touches et propriétés des événements</li>
<li><a href="https://gafferongames.com/post/fix_your_timestep/">Corrigez votre pas de temps !</a> — l’article de référence de Glenn Fiedler sur les boucles de jeu à pas de temps fixe</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Activer les threads Wasm (SharedArrayBuffer) avec COOP/COEP]]></title>
            <link>https://app.cinevva.com/fr/tutorials/coop-coep-sharedarraybuffer</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/coop-coep-sharedarraybuffer</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Un guide pratique prêt à copier-coller sur l’isolation interorigines : en-têtes requis, pièges courants et exemples de configuration de serveur pour SharedArrayBuffer et les threads WebAssembly. Inclut un outil gratuit permettant de vérifier si une URL est isolée entre les origines.]]></description>
            <content:encoded><![CDATA[<h1>Activer les threads Wasm (SharedArrayBuffer) avec COOP/COEP</h1>
<p>Si votre jeu web (ou l’exportation de votre moteur) utilise des <strong>threads WebAssembly</strong>, il s’appuie souvent sur <code>SharedArrayBuffer</code>. Les navigateurs modernes exigent pour cela une <strong>isolation interorigines</strong>.</p>
<p><img src="/img/cross-origin-isolation.svg" alt="Schéma : en-têtes d’isolation interorigines"></p>
<h2>1) Vérification rapide : votre page est-elle isolée ?</h2>
<p>Ouvrez les outils de développement et exécutez :</p>
<pre><code class="language-js">self.crossOriginIsolated
</code></pre>
<p>Si la valeur est <code>true</code>, vous êtes dans l’état de sécurité requis pour utiliser <code>SharedArrayBuffer</code>.</p>
<p>Référence officielle :</p>
<ul>
<li><code>https://developer.mozilla.org/docs/Web/API/Window/crossOriginIsolated</code></li>
</ul>
<h2>2) Les deux en-têtes nécessaires</h2>
<p>Envoyez ces en-têtes de réponse avec votre HTML (et généralement dans l’ensemble de votre application) :</p>
<pre><code class="language-http">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<p>Références officielles :</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) La principale raison pour laquelle cela « ne fonctionne toujours pas » : les sous-ressources</h2>
<p>Avec <code>COEP: require-corp</code>, votre page ne peut plus intégrer librement des ressources provenant d’autres origines.</p>
<p>Si vous chargez un script, une image, un worker, une vidéo ou toute autre ressource depuis une autre origine, cette ressource doit explicitement autoriser son utilisation via :</p>
<ul>
<li>CORS (<code>Access-Control-Allow-Origin</code>) <strong>ou</strong></li>
<li>CORP (<code>Cross-Origin-Resource-Policy</code>)</li>
</ul>
<p>Référence officielle :</p>
<ul>
<li><code>https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cross-Origin_Resource_Policy</code></li>
</ul>
<h2>4) Configurations à copier-coller</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) Liste de vérification pour le débogage</h2>
<ul>
<li>Vérifiez que les en-têtes figurent bien dans la réponse du <strong>document</strong>, dans le panneau Réseau.</li>
<li>Vérifiez la valeur de <code>self.crossOriginIsolated</code>.</li>
<li>Recherchez les requêtes bloquées (un message courant dans les outils de développement contient « blocked … by COEP »).</li>
<li>Supprimez ou auto-hébergez les scripts tiers qui ne prennent pas en charge CORS/CORP.</li>
</ul>
<h2>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/guides/web-games-stack-2026">Les technologies des jeux web en 2026</a></li>
<li><a href="/fr/tutorials/web-workers-game-logic">Les Web Workers pour la logique du jeu</a> — utiliser des Workers qui bénéficient de SharedArrayBuffer</li>
<li><a href="/fr/tutorials/websocket-multiplayer-basics">Les bases du multijoueur avec WebSocket</a> — pour les jeux multijoueurs nécessitant une isolation interorigines</li>
<li><a href="/fr/tutorials/iframe-embedding-games">Intégrer des jeux avec une iframe</a> — comprendre l’incidence des en-têtes COOP/COEP sur les intégrations par iframe</li>
</ul>
<h2>Ressources externes</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> — référence sur l’en-tête COOP</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Embedder-Policy">MDN : Cross-Origin-Embedder-Policy</a> — référence sur l’en-tête COEP</li>
<li><a href="https://web.dev/articles/cross-origin-isolation-guide">web.dev : Isoler votre site web entre les origines</a> — guide pratique de déploiement</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Gestion des entrées de jeu (clavier, souris, tactile, manette)]]></title>
            <link>https://app.cinevva.com/fr/tutorials/game-input-handling</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/game-input-handling</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Gestion unifiée des entrées pour les jeux web : prise en charge du clavier, de la souris, du tactile et des manettes avec des modèles de code pratiques.]]></description>
            <content:encoded><![CDATA[<h1>Gestion des entrées de jeu</h1>
<p>Une bonne gestion des entrées peut faire toute la différence dans un jeu. Des commandes qui réagissent avec retard, des entrées ignorées ou des associations de boutons déroutantes frustrent les joueurs avant même qu'ils ne découvrent le contenu de votre jeu. Ce tutoriel explique comment gérer le clavier, la souris, le tactile et les manettes de manière unifiée.</p>
<p><strong>Essayez d'abord :</strong></p>
<InputDemo />
<h2>Le modèle d'état des entrées</h2>
<p>L'approche naïve consiste à réagir directement aux événements : lorsque l'utilisateur appuie sur une touche, effectuer une action. Cela pose problème. Les événements se déclenchent à des moments imprévisibles, tandis que votre boucle de jeu s'exécute à une fréquence fixe. Si vous déplacez le joueur dans le gestionnaire <code>keydown</code>, la vitesse de déplacement dépend de la fréquence de répétition des touches, qui varie selon le système d'exploitation.</p>
<p>À la place, conservez un objet représentant l'état des entrées, que votre boucle de jeu peut consulter :</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>Les gestionnaires d'événements définissent ces indicateurs. La boucle de jeu les consulte. Ce découplage simplifie tout : votre logique de mise à jour n'a pas besoin de savoir si l'entrée provient du clavier, du tactile ou d'une manette.</p>
<h2>Clavier</h2>
<p>Utilisez <code>e.code</code> plutôt que <code>e.key</code>, car il correspond à la position physique de la touche. Ainsi, les commandes WASD fonctionnent correctement sur les dispositions de clavier autres que QWERTY.</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>La méthode <code>e.preventDefault()</code> empêche les touches fléchées de faire défiler la page. Appelez-la uniquement pour les touches que vous gérez.</p>
<h2>Souris</h2>
<p>Les coordonnées de la souris sont exprimées dans l'espace de l'écran. Vous devez les convertir dans l'espace du canevas :</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>Si votre canevas est redimensionné (consultez le tutoriel sur le canevas adaptatif), vous devrez diviser les valeurs par le facteur d'échelle afin d'obtenir les coordonnées du jeu.</p>
<h2>Tactile</h2>
<p>Le tactile fonctionne de manière similaire à la souris, mais vous devez utiliser <code>e.preventDefault()</code> pour empêcher le navigateur de faire défiler ou de zoomer la page :</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>L'option <code>{ passive: false }</code> est nécessaire, car les navigateurs modernes utilisent par défaut des écouteurs tactiles passifs pour améliorer les performances. Sans elle, <code>preventDefault</code> ne fonctionnera pas.</p>
<h2>Croix directionnelle virtuelle pour mobile</h2>
<p>Pour les jeux qui nécessitent des commandes directionnelles sur mobile, ajoutez des boutons à l'écran :</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>Donnez à ces boutons une taille suffisante pour être facilement utilisés avec les pouces (au moins 44x44 pixels) et placez-les dans les coins inférieurs.</p>
<h2>Manette</h2>
<p>Contrairement au clavier et à la souris, l'état d'une manette n'est pas piloté par des événements. Vous devez l'interroger à chaque image :</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>Appelez <code>pollGamepads()</code> au début de votre fonction de mise à jour. Le seuil de 0,5 appliqué aux sticks analogiques crée une zone morte afin que leurs petits mouvements ne soient pas enregistrés comme des entrées.</p>
<p>Vous pouvez détecter la connexion et la déconnexion des manettes :</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>Utiliser les entrées dans votre boucle de jeu</h2>
<p>Votre fonction de mise à jour n'a désormais plus qu'à consulter l'état des entrées :</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>Cela fonctionne que l'entrée provienne du clavier, du tactile ou d'une manette. La logique du jeu n'a pas besoin de le savoir.</p>
<h2>Appui initial ou maintien</h2>
<p>Vous souhaitez parfois détecter si un bouton vient d'être pressé pendant l'image actuelle, plutôt que de savoir s'il est maintenu enfoncé. Le saut en est un bon exemple : vous voulez un saut par appui, et non des sauts continus tant que le bouton reste enfoncé.</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>Appelez <code>endFrame()</code> à la fin de votre boucle de mise à jour. Utilisez ensuite <code>wasJustPressed('action')</code> à la place de <code>input.action</code> pour les actions qui ne doivent être déclenchées qu'une seule fois.</p>
<h2>Perte de focus</h2>
<p>Lorsque la fenêtre du navigateur perd le focus, les événements du clavier cessent de se déclencher. Si le joueur maintenait une touche enfoncée, celle-ci reste alors considérée comme active :</p>
<pre><code class="language-js">window.addEventListener('blur', () =&gt; {
  input.left = input.right = input.up = input.down = input.action = false
})
</code></pre>
<p>Sans cela, le joueur continue de se déplacer lorsqu'il utilise Alt+Tab pour changer de fenêtre.</p>
<h2>Bonnes pratiques</h2>
<p>Prenez en charge à la fois les touches WASD et les touches fléchées, car chaque joueur a ses préférences. Permettez de réassigner les commandes et enregistrez la configuration dans localStorage. Proposez des commandes tactiles sur les appareils mobiles. Testez avec de vraies manettes, car les manettes Xbox, PlayStation et génériques se comportent toutes de façon légèrement différente. Enfin, gérez toujours correctement la perte de focus.</p>
<h2>Ressources complémentaires</h2>
<p><a href="/fr/tutorials/gamepad-api">Présentation approfondie de l'API Gamepad</a> présente des fonctionnalités avancées des manettes, comme les vibrations et la prise en charge de plusieurs joueurs.</p>
<p><a href="/fr/tutorials/mobile-friendly-web-games">Jeux web adaptés aux mobiles</a> présente les entrées tactiles plus en détail.</p>
<p><a href="/fr/tutorials/pointer-lock-fps">Pointer Lock pour les jeux FPS</a> explique comment capturer la souris pour les commandes à la première personne.</p>
<p><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu Canvas 2D</a> montre comment intégrer le modèle d'état des entrées dans une boucle de jeu complète.</p>
<p><a href="/fr/tutorials/websocket-multiplayer-basics">Principes de base du multijoueur avec WebSocket</a> explique comment synchroniser les entrées entre des joueurs en réseau.</p>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code">MDN : KeyboardEvent.code</a> — valeurs des touches physiques pour l'association des entrées</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events">MDN : événements de pointeur</a> — événements unifiés pour la souris et le tactile</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN : API Gamepad</a> — prise en charge des manettes dans le navigateur</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN : événements tactiles</a> — gestion du multipoint sur mobile</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Physique pour les jeux web]]></title>
            <link>https://app.cinevva.com/fr/tutorials/game-physics-libraries</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/game-physics-libraries</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Comparez les moteurs physiques pour les jeux web : Rapier, Cannon-es, Ammo.js, Matter.js et Planck.js. Tests de performances, exemples de code et cas d’utilisation de chacun.]]></description>
            <content:encoded><![CDATA[<h1>Physique pour les jeux web</h1>
<p>La simulation physique donne vie aux jeux : objets qui tombent, balles qui rebondissent, ragdolls, véhicules et environnements destructibles. Ce guide présente les principales bibliothèques de physique disponibles pour les jeux web, avec de véritables exemples de code et une évaluation honnête de leurs avantages et inconvénients.</p>
<h2>Vue d’ensemble</h2>
<table>
<thead>
<tr>
<th>Moteur</th>
<th style="text-align:center">Dimension</th>
<th style="text-align:center">Performances</th>
<th style="text-align:center">Taille</th>
<th style="text-align:center">Difficulté</th>
<th style="text-align:center">Corps souples</th>
<th style="text-align:center">Véhicules</th>
<th style="text-align:center">CCD</th>
<th style="text-align:center">Déterminisme</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 Mo</td>
<td style="text-align:center">Moyenne</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 Ko</td>
<td style="text-align:center">Facile</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 Mo</td>
<td style="text-align:center">Difficile</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">Difficile</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 Ko</td>
<td style="text-align:center">Facile</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 Ko</td>
<td style="text-align:center">Facile</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 Ko</td>
<td style="text-align:center">Moyenne</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 Ko</td>
<td style="text-align:center">Moyenne</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 Ko</td>
<td style="text-align:center">Difficile</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>Légende :</strong></p>
<ul>
<li>CCD = détection continue des collisions (empêche les objets rapides de traverser les murs)</li>
<li>⚠️ Déterminisme = possible avec un pas de temps fixe, mais non garanti entre les différentes plateformes</li>
</ul>
<h3>Recommandations rapides</h3>
<table>
<thead>
<tr>
<th>Votre situation</th>
<th>Meilleur choix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Jeu 3D en production</td>
<td><strong>Rapier</strong> — meilleures performances, API moderne et développement actif</td>
</tr>
<tr>
<td>Apprentissage / prototype</td>
<td><strong>Cannon-es</strong> (3D) ou <strong>Matter.js</strong> (2D) — API simples et débogage facile</td>
</tr>
<tr>
<td>Physique des véhicules</td>
<td><strong>Rapier</strong> ou <strong>Ammo.js</strong> — tous deux disposent de contrôleurs de véhicule par lancer de rayons</td>
</tr>
<tr>
<td>Corps souples, tissus, cordes</td>
<td><strong>Ammo.js</strong> ou <strong>Jolt (JoltPhysics.js)</strong> — tous deux prennent en charge les tissus et les corps souples ; le portage WASM de Jolt est maintenu plus activement</td>
</tr>
<tr>
<td>Jeu de plateformes 2D précis</td>
<td><strong>Planck.js</strong> — algorithmes Box2D et comportement déterministe avec un pas de temps fixe</td>
</tr>
<tr>
<td>Performances 2D maximales</td>
<td><strong>Box2D WASM</strong> — vitesse native dans le navigateur</td>
</tr>
<tr>
<td>Taille de bundle minimale</td>
<td><strong>Oimo.js</strong> (3D) ou <strong>Matter.js</strong> (2D)</td>
</tr>
</tbody>
</table>
<hr>
<h2>Comparaison détaillée</h2>
<h3>Moteurs physiques 3D</h3>
<table>
<thead>
<tr>
<th>Moteur</th>
<th>Langage</th>
<th>Taille</th>
<th>Performances</th>
<th>Idéal pour</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Rapier</strong></td>
<td>Rust/WASM</td>
<td>~1,4 Mo</td>
<td>Excellentes</td>
<td>Jeux en production, simulations complexes</td>
</tr>
<tr>
<td><strong>Cannon-es</strong></td>
<td>JavaScript</td>
<td>~150 Ko</td>
<td>Bonnes</td>
<td>Prototypes, jeux simples</td>
</tr>
<tr>
<td><strong>Ammo.js</strong></td>
<td>C++/WASM</td>
<td>~1-2 Mo</td>
<td>Excellentes</td>
<td>Fonctionnalités AAA, corps souples, véhicules</td>
</tr>
<tr>
<td><strong>Jolt</strong></td>
<td>C++/WASM</td>
<td>WASM</td>
<td>Excellentes</td>
<td>Fonctionnalités AAA avec une maintenance active</td>
</tr>
<tr>
<td><strong>Oimo.js</strong></td>
<td>JavaScript</td>
<td>~100 Ko</td>
<td>Bonnes</td>
<td>Jeux simples, prototypes rapides</td>
</tr>
</tbody>
</table>
<h3>Moteurs physiques 2D</h3>
<table>
<thead>
<tr>
<th>Moteur</th>
<th>Langage</th>
<th>Taille</th>
<th>Performances</th>
<th>Idéal pour</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Matter.js</strong></td>
<td>JavaScript</td>
<td>~80 Ko</td>
<td>Bonnes</td>
<td>Jeux visuels, prototypes</td>
</tr>
<tr>
<td><strong>Planck.js</strong></td>
<td>JavaScript</td>
<td>~120 Ko</td>
<td>Bonnes</td>
<td>Jeux de plateformes, physique précise</td>
</tr>
<tr>
<td><strong>p2-es</strong></td>
<td>JavaScript</td>
<td>~100 Ko</td>
<td>Bonnes</td>
<td>Contraintes, mécanismes</td>
</tr>
<tr>
<td><strong>Box2D WASM</strong></td>
<td>C++/WASM</td>
<td>~300 Ko</td>
<td>Excellentes</td>
<td>Grand nombre de corps</td>
</tr>
</tbody>
</table>
<hr>
<h2>Moteurs physiques 3D</h2>
<h3>Rapier — Le choix moderne</h3>
<p>Rapier est un moteur physique écrit en Rust, avec des liaisons JavaScript/WASM. C’est l’option la plus performante pour les jeux web en 2025-2026, avec des gains de vitesse de 2 à 5 fois par rapport à sa version de 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>Configuration de base :</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>Intégration à la boucle de jeu :</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>Détection des collisions :</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>Articulations :</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>Contrôleur de véhicule :</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>Avantages :</strong></p>
<ul>
<li>Meilleures performances (WASM + SIMD)</li>
<li>Excellente précision de la détection des collisions</li>
<li>Déterminisme multiplateforme (mêmes entrées = mêmes sorties)</li>
<li>Développement actif et API moderne</li>
<li>Détection continue des collisions (aucune traversée des obstacles)</li>
<li>Contrôleurs de personnage et de véhicule intégrés</li>
</ul>
<p><strong>Inconvénients :</strong></p>
<ul>
<li>Bundle plus volumineux (~1,4 Mo)</li>
<li>Initialisation asynchrone obligatoire</li>
<li>API plus complexe que celles des solutions en JavaScript pur</li>
<li>Le débogage de WASM peut être délicat</li>
</ul>
<hr>
<h3>Cannon-es — Simple et efficace</h3>
<p>Cannon-es est le fork maintenu de Cannon.js. Écrit entièrement en JavaScript, il est facile à comprendre et convient parfaitement à l’apprentissage et au prototypage.</p>
<p><strong>Installation :</strong></p>
<pre><code class="language-bash">npm install cannon-es
</code></pre>
<p><strong>Configuration de base :</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>Boucle de jeu :</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>Événements de collision :</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>Contraintes :</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>Avantages :</strong></p>
<ul>
<li>JavaScript pur, sans la complexité de WASM</li>
<li>Bundle peu volumineux (~150 Ko)</li>
<li>Facile à apprendre et à déboguer</li>
<li>Fonctionne partout</li>
<li>Excellente intégration avec Three.js</li>
<li>Bonne documentation</li>
</ul>
<p><strong>Inconvénients :</strong></p>
<ul>
<li>Plus lent que les solutions WASM</li>
<li>Montre ses limites avec un grand nombre de corps (&gt;100)</li>
<li>Prise en charge limitée des maillages triangulés</li>
<li>Aucune CCD intégrée (les objets peuvent traverser les obstacles)</li>
<li>Développement moins actif</li>
</ul>
<hr>
<h3>Ammo.js — Toute la puissance de Bullet Physics</h3>
<p>Ammo.js est le moteur Bullet Physics compilé en WebAssembly. Il offre un maximum de fonctionnalités, notamment les corps souples, les véhicules et les contraintes avancées.</p>
<p><strong>Installation :</strong></p>
<pre><code class="language-bash">npm install ammo.js
# Or use from CDN
</code></pre>
<p><strong>Configuration de base :</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>Corps souples (tissu) :</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>Physique des véhicules :</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>Avantages :</strong></p>
<ul>
<li>Ensemble de fonctionnalités le plus complet</li>
<li>Corps souples, tissus et cordes</li>
<li>Physique avancée des véhicules</li>
<li>Éprouvé en production (utilisé dans de nombreux jeux AAA)</li>
<li>Hautement configurable
<strong>Inconvénients :</strong></li>
<li>API complexe et verbeuse</li>
<li>Taille de bundle importante</li>
<li>Gestion de la mémoire nécessaire (destruction des objets)</li>
<li>Courbe d’apprentissage abrupte</li>
<li>Documentation dispersée</li>
<li>Pas de déterminisme multiplateforme (uniquement sur un même appareil, avec une configuration rigoureuse)</li>
</ul>
<hr>
<h3>Jolt — physique 3D moderne de niveau AAA</h3>
<p>Jolt Physics est le moteur utilisé par des jeux comme Horizon Forbidden West, et JoltPhysics.js le rend accessible dans le navigateur grâce à un portage WASM. Il constitue un excellent compromis entre Rapier et Ammo.js : davantage de fonctionnalités que Rapier (corps mous, tissus, contrôleur de véhicule à roues), avec une maintenance active qui fait défaut à Ammo.js. Le paquet npm est <code>jolt-physics</code>, et des intégrations prêtes à l’emploi existent pour React Three Fiber (<code>@react-three/jolt</code>) et Babylon.js.</p>
<pre><code class="language-bash">npm install jolt-physics
</code></pre>
<p>Comme Rapier, il s’agit d’un module WASM initialisé de manière asynchrone, doté d’une API verbeuse mais complète qui reproduit l’interface C++. Choisissez Jolt si vous recherchez des fonctionnalités comparables à celles de Bullet (corps mous, véhicules), tout en préférant une base de code moderne et maintenue à Ammo.js.</p>
<hr>
<h3>Oimo.js — léger et rapide</h3>
<p>Oimo.js est un moteur physique 3D léger. Il convient parfaitement aux jeux simples qui ne nécessitent pas de fonctionnalités avancées.</p>
<p><strong>Installation :</strong></p>
<pre><code class="language-bash">npm install oimo
</code></pre>
<p><strong>Configuration de base :</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>Boucle de jeu :</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>Avantages :</strong></p>
<ul>
<li>Très léger (environ 100 Ko)</li>
<li>API simple</li>
<li>Bonnes performances pour les scènes basiques</li>
<li>Prise en charge native de Babylon.js</li>
</ul>
<p><strong>Inconvénients :</strong></p>
<ul>
<li>Formes limitées (primitives uniquement)</li>
<li>Aucun corps mou</li>
<li>Documentation peu fournie</li>
<li>Développement moins actif</li>
<li>Choix limité d’articulations</li>
</ul>
<hr>
<h2>Moteurs physiques 2D</h2>
<h3>Matter.js — élégant et intuitif</h3>
<p>Matter.js est un moteur physique 2D riche en fonctionnalités, doté d’excellents outils de rendu et de débogage.</p>
<p><strong>Installation :</strong></p>
<pre><code class="language-bash">npm install matter-js
</code></pre>
<p><strong>Configuration de base :</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>Événements de collision :</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>Contraintes :</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>Interaction à la souris :</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>Avantages :</strong></p>
<ul>
<li>Beau rendu par défaut</li>
<li>Excellent pour l’apprentissage et le prototypage</li>
<li>API intuitive</li>
<li>Bonne documentation</li>
<li>Communauté active</li>
<li>Déterministe par défaut — Matter.Runner utilise désormais un pas de temps fixe et déterministe (le pas de temps non fixe a été supprimé dans la version 0.20.0)</li>
</ul>
<p><strong>Inconvénients :</strong></p>
<ul>
<li>Aucun CCD (les objets rapides peuvent traverser les obstacles — utilisez des sous-pas pour atténuer ce problème)</li>
<li>Problèmes de performances avec de nombreux corps</li>
<li>Aucune option WASM</li>
<li>Précision limitée pour les simulations complexes</li>
</ul>
<hr>
<h3>Planck.js — Box2D pour JavaScript</h3>
<p>Planck.js est une réécriture complète de Box2D en JavaScript. Son moteur physique est éprouvé et déterministe lorsqu’un pas de temps fixe est utilisé.</p>
<p><strong>Installation :</strong></p>
<pre><code class="language-bash">npm install planck
</code></pre>
<p><strong>Configuration de base :</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>Boucle de jeu à pas de temps fixe :</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>Fonctions de rappel de collision :</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>Articulations :</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>Avantages :</strong></p>
<ul>
<li>Déterministe avec un pas de temps fixe</li>
<li>Bien documenté (la documentation de Box2D s’applique)</li>
<li>Bonnes performances</li>
<li>Prise en charge de TypeScript</li>
<li>Bundle de petite taille</li>
</ul>
<p><strong>Inconvénients :</strong></p>
<ul>
<li>Le CCD ne prend pas en charge les articulations ; les objets rapides reliés par des articulations peuvent donc s’étirer</li>
<li>Courbe d’apprentissage plus abrupte que celle de Matter.js</li>
<li>Aucun moteur de rendu intégré</li>
<li>Particularités de Box2D (l’échelle des unités est importante)</li>
</ul>
<hr>
<h3>p2-es — physique 2D flexible</h3>
<p>p2-es est le fork maintenu de p2.js. Il convient parfaitement aux jeux qui nécessitent des contraintes et des mécanismes complexes.</p>
<p><strong>Installation :</strong></p>
<pre><code class="language-bash">npm install p2-es
</code></pre>
<p><strong>Configuration de base :</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>Contraintes avancées :</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>Matériaux de contact :</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>Avantages :</strong></p>
<ul>
<li>Système de contraintes riche</li>
<li>Matériaux de contact</li>
<li>Bien adapté aux mécanismes et aux machines</li>
<li>Mise en veille des corps pour améliorer les performances</li>
<li>Modules ES compatibles avec le tree shaking</li>
</ul>
<p><strong>Inconvénients :</strong></p>
<ul>
<li>Aucun moteur de rendu intégré</li>
<li>Certaines paires de formes ne sont pas prises en charge</li>
<li>Moins actif que les autres solutions</li>
<li>Lacunes dans la documentation</li>
</ul>
<hr>
<h2>Conseils de performance</h2>
<h3>1. Utilisez un pas de temps fixe</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. Mettez en veille les corps inactifs</h3>
<p>La plupart des moteurs prennent en charge la mise en veille. Activez-la :</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. Utilisez des formes de collision simples</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. Réduisez le nombre d’itérations du solveur (avec prudence)</h3>
<pre><code class="language-js">// Cannon-es
world.solver.iterations = 5 // Default 10

// Rapier - configured at world creation
</code></pre>
<h3>5. Exécutez la simulation physique dans un Web Worker</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>Quel moteur choisir selon le besoin</h2>
<table>
<thead>
<tr>
<th>Scénario</th>
<th>Recommandation</th>
<th>Pourquoi</th>
</tr>
</thead>
<tbody>
<tr>
<td>Jeu 3D en production</td>
<td><strong>Rapier</strong></td>
<td>Meilleures performances, API moderne, véhicules</td>
</tr>
<tr>
<td>Prototype de jeu 3D</td>
<td><strong>Cannon-es</strong></td>
<td>Simple et facile à déboguer</td>
</tr>
<tr>
<td>Corps mous, tissus, cordes</td>
<td><strong>Ammo.js</strong> ou <strong>Jolt</strong></td>
<td>Les deux prennent en charge les tissus et les corps mous ; le portage WASM de Jolt (JoltPhysics.js) est maintenu plus activement</td>
</tr>
<tr>
<td>Jeu 3D simple pour navigateur</td>
<td><strong>Oimo.js</strong></td>
<td>Minuscule et suffisant</td>
</tr>
<tr>
<td>Game jam 2D</td>
<td><strong>Matter.js</strong></td>
<td>Mise en place rapide, rendu intégré</td>
</tr>
<tr>
<td>Jeu de plateformes précis</td>
<td><strong>Planck.js</strong></td>
<td>Déterminisme avec un pas de temps fixe, bonne documentation</td>
</tr>
<tr>
<td>Mécanismes 2D complexes</td>
<td><strong>p2-es</strong></td>
<td>Système de contraintes riche</td>
</tr>
<tr>
<td>Jeu 2D aux performances critiques</td>
<td><strong>Box2D WASM</strong></td>
<td>Option 2D la plus rapide, véritable CCD</td>
</tr>
</tbody>
</table>
<hr>
<h2>Intégration avec les moteurs de rendu</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>Pièges courants</h2>
<h3>L’échelle est importante</h3>
<p>Les moteurs physiques fonctionnent mieux avec une échelle réaliste (1 unité = 1 mètre). N’utilisez pas directement des coordonnées en pixels.</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>Fuites de mémoire avec les moteurs WASM</h3>
<p>Nettoyez toujours les corps lorsque vous les supprimez :</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>Effet tunnel (les objets se traversent)</h3>
<p>Les objets se déplaçant rapidement peuvent traverser les parois fines. Solutions :</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>Articles connexes</h2>
<ul>
<li><a href="/fr/tutorials/webgl-fundamentals-for-games">Fondamentaux de WebGL pour les créateurs de jeux</a></li>
<li><a href="/fr/tutorials/web-workers-game-logic">Web Workers pour la logique de jeu</a> — déchargez les calculs physiques sur un thread Worker</li>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu avec Canvas 2D</a> — le modèle à pas de temps fixe utilisé par tous les moteurs physiques</li>
<li><a href="/fr/guides/web-games-stack-2026">Les technologies des jeux web en 2026</a> — comment les moteurs physiques s’intègrent à l’ensemble de la pile technologique</li>
<li><a href="/fr/guides/web-game-engines-comparison">Comparatif des moteurs de jeux web</a> — moteurs intégrant la gestion de la physique</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://rapier.rs/docs/">Documentation de Rapier</a> — documentation officielle et exemples sur le moteur physique Rapier</li>
<li><a href="https://pmndrs.github.io/cannon-es/docs/">Documentation de Cannon-es</a> — référence de l’API et guides</li>
<li><a href="https://brm.io/matter-js/docs/">Documentation de Matter.js</a> — référence complète de l’API</li>
<li><a href="https://piqnt.com/planck.js/">Documentation de Planck.js</a> — Box2D pour JavaScript</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[API Gamepad pour les jeux web]]></title>
            <link>https://app.cinevva.com/fr/tutorials/gamepad-api</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/gamepad-api</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Prise en charge complète des manettes : détection, mappage des boutons, sticks analogiques, vibrations et gestion de plusieurs manettes. Des contrôles dignes d’une console sur le web.]]></description>
            <content:encoded><![CDATA[<h1>API Gamepad pour les jeux web</h1>
<p>L’API Gamepad apporte aux jeux web une prise en charge des manettes digne d’une console. Prenez en charge les manettes Xbox, PlayStation, Switch Pro et génériques.</p>
<h2>1) Détecter les manettes</h2>
<pre><code class="language-js">window.addEventListener('gamepadconnected', (e) =&gt; {
  console.log('Manette connectée :', e.gamepad.id)
  console.log('Index :', e.gamepad.index)
  console.log('Boutons :', e.gamepad.buttons.length)
  console.log('Axes :', e.gamepad.axes.length)
})

window.addEventListener('gamepaddisconnected', (e) =&gt; {
  console.log('Manette déconnectée :', e.gamepad.id)
})
</code></pre>
<h2>2) Interroger l’état des manettes</h2>
<p>L’état des manettes doit être interrogé régulièrement, et non géré par des événements :</p>
<pre><code class="language-js">function pollGamepads() {
  const gamepads = navigator.getGamepads()
  
  for (const gamepad of gamepads) {
    if (gamepad) {
      processGamepad(gamepad)
    }
  }
}

// À appeler dans votre boucle de jeu
function gameLoop() {
  pollGamepads()
  update()
  render()
  requestAnimationFrame(gameLoop)
}
</code></pre>
<h2>3) Mappage standard des boutons</h2>
<p>Le mappage « standard » pour la plupart des manettes Xbox et PlayStation :</p>
<pre><code class="language-js">const BUTTONS = {
  A: 0,           // Croix (PS) / A (Xbox)
  B: 1,           // Cercle (PS) / B (Xbox)
  X: 2,           // Carré (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,         // Appui sur le stick gauche
  R3: 11,         // Appui sur le stick droit
  DPAD_UP: 12,
  DPAD_DOWN: 13,
  DPAD_LEFT: 14,
  DPAD_RIGHT: 15,
  HOME: 16,       // Bouton PS / bouton Xbox
}

const AXES = {
  LEFT_X: 0,
  LEFT_Y: 1,
  RIGHT_X: 2,
  RIGHT_Y: 3,
}
</code></pre>
<h2>4) Lire les boutons et les sticks</h2>
<pre><code class="language-js">function processGamepad(gamepad) {
  // Boutons (numériques ou analogiques)
  const jump = gamepad.buttons[BUTTONS.A].pressed
  const shoot = gamepad.buttons[BUTTONS.X].pressed
  
  // Gâchettes (analogiques, de 0 à 1)
  const leftTrigger = gamepad.buttons[BUTTONS.LT].value
  const rightTrigger = gamepad.buttons[BUTTONS.RT].value
  
  // Sticks (analogiques, de -1 à 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) Gérer la zone morte</h2>
<p>Les sticks restent rarement exactement à 0 :</p>
<pre><code class="language-js">function applyDeadzone(value, deadzone = 0.15) {
  if (Math.abs(value) &lt; deadzone) return 0
  
  // Remettre à l’échelle la plage restante entre 0 et 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) Zone morte radiale, mieux adaptée aux déplacements en 2D</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,
  }
}

// Utilisation
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) Gestionnaire de manettes complet</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('Connectée :', 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,
      
      // Boutons de façade
      a: gp.buttons[0].pressed,
      b: gp.buttons[1].pressed,
      x: gp.buttons[2].pressed,
      y: gp.buttons[3].pressed,
      
      // Boutons supérieurs
      lb: gp.buttons[4].pressed,
      rb: gp.buttons[5].pressed,
      
      // Gâchettes (analogiques)
      lt: gp.buttons[6].value,
      rt: gp.buttons[7].value,
      
      // Croix directionnelle
      dpadUp: gp.buttons[12].pressed,
      dpadDown: gp.buttons[13].pressed,
      dpadLeft: gp.buttons[14].pressed,
      dpadRight: gp.buttons[15].pressed,
      
      // Commandes système
      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)
    }
  }
}

// Utilisation
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) Vibrations et retour haptique</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,   // Moteur haute fréquence
      strongMagnitude, // Moteur basse fréquence
    })
  }
}

// Utilisation
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) Plusieurs joueurs</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) {
    // ... comme précédemment
  }
}

// Dans la boucle de jeu
const multiplayer = new LocalMultiplayer(4)

function update() {
  const players = multiplayer.getActivePlayers()
  
  for (const { index, state } of players) {
    updatePlayer(index, state)
  }
}
</code></pre>
<h2>10) Solution de repli et contrôles combinés</h2>
<p>Prenez en charge à la fois le clavier et la manette :</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()
    
    // Combiner les contrôles (la manette est prioritaire pour les entrées analogiques)
    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>À consulter également</h2>
<ul>
<li><a href="/fr/tutorials/game-input-handling">Gestion des contrôles de jeu</a></li>
<li><a href="/fr/tutorials/mobile-friendly-web-games">Jeux web adaptés aux appareils mobiles</a></li>
<li><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu Canvas 2D</a></li>
<li><a href="/fr/tutorials/pointer-lock-fps">Pointer Lock pour les jeux FPS</a> — combiner une manette avec la visée à la souris</li>
<li><a href="/fr/guides/co-op-game-design">Conception d’un jeu en coopération</a> — concevoir des contrôles pour plusieurs manettes en coopération locale</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN : API Gamepad</a> — référence complète de l’API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API">MDN : utiliser l’API Gamepad</a> — guide pas à pas</li>
<li><a href="https://gamepad-tester.com/">Gamepad Tester</a> — testez le mappage de votre manette dans le navigateur</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bonnes pratiques d’intégration des jeux dans une iframe]]></title>
            <link>https://app.cinevva.com/fr/tutorials/iframe-embedding-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/iframe-embedding-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Intégrez correctement des jeux web dans des iframes : sécurité, communication, plein écran et exigences des plateformes. Rendez votre jeu intégrable partout.]]></description>
            <content:encoded><![CDATA[<h1>Bonnes pratiques d’intégration des jeux dans une iframe</h1>
<p>De nombreuses plateformes (<a href="http://Itch.io">Itch.io</a>, Newgrounds, Cinevva, votre propre site) intègrent les jeux dans des iframes. Ce tutoriel explique comment garantir le bon fonctionnement de votre jeu lorsqu’il est intégré.</p>
<h2>1) Configuration de base pour l’intégration</h2>
<p>Votre jeu doit fonctionner sans nécessiter le contrôle total de la page :</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) Détecter le contexte d’une iframe</h2>
<pre><code class="language-js">const isEmbedded = window.self !== window.top

if (isEmbedded) {
  // Adapter le comportement au contexte d’intégration
  hideExternalLinks()
  adjustUIForSmallSize()
}

function hideExternalLinks() {
  document.querySelectorAll('a[target=&quot;_blank&quot;]').forEach(link =&gt; {
    link.style.display = 'none'
  })
}
</code></pre>
<h2>3) Autoriser les permissions nécessaires dans l’iframe</h2>
<p>La page parente contrôle ce que votre iframe peut faire. Voici les permissions courantes :</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>Votre jeu doit gérer correctement les permissions manquantes :</p>
<pre><code class="language-js">// Vérifier si le mode plein écran est disponible
const canFullscreen = document.fullscreenEnabled || document.webkitFullscreenEnabled

if (!canFullscreen) {
  document.getElementById('fullscreen-btn').style.display = 'none'
}
</code></pre>
<h2>4) Mode plein écran depuis une iframe</h2>
<p>Le mode plein écran nécessite l’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) {
    // Plein écran non autorisé — afficher un message
    showMessage('Le mode plein écran n’est pas disponible lorsque le jeu est intégré')
  }
}
</code></pre>
<h2>5) Communiquer avec la page parente</h2>
<p>Utilisez <code>postMessage</code> pour communiquer de manière sécurisée entre différentes origines :</p>
<p><strong>Dans votre jeu :</strong></p>
<pre><code class="language-js">// Envoyer un message à la page parente
function notifyParent(type, data) {
  if (window.parent !== window) {
    window.parent.postMessage({ type, data, source: 'game' }, '*')
  }
}

// Recevoir les messages de la page parente
window.addEventListener('message', (event) =&gt; {
  // Valider l’origine si nécessaire
  // 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
  }
})

// Signaler que le jeu est prêt
window.addEventListener('load', () =&gt; {
  notifyParent('ready', { width: 800, height: 600 })
})

// Signaler les événements du jeu
function onGameOver(score) {
  notifyParent('gameover', { score })
}
</code></pre>
<p><strong>Dans la page parente :</strong></p>
<pre><code class="language-js">const iframe = document.getElementById('game-iframe')

iframe.addEventListener('load', () =&gt; {
  // Écouter les messages du jeu
  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('Jeu prêt :', data)
    }
    if (type === 'gameover') {
      showScoreModal(data.score)
    }
  })
})

// Envoyer des commandes au jeu
function pauseGame() {
  iframe.contentWindow.postMessage({ type: 'pause' }, '*')
}
</code></pre>
<h2>6) Gérer le focus</h2>
<p>Les iframes peuvent perdre le focus, ce qui interrompt les commandes au clavier :</p>
<pre><code class="language-js">// Donner automatiquement le focus au canvas lors d’un clic
canvas.addEventListener('click', () =&gt; {
  canvas.focus()
})

// Rendre le canvas sélectionnable
canvas.tabIndex = 1

// Gérer la perte du focus
window.addEventListener('blur', () =&gt; {
  // Réinitialiser les touches maintenues
  input.left = input.right = input.up = input.down = false
  
  if (isEmbedded) {
    // Mettre éventuellement le jeu en pause
    // pauseGame()
  }
})

// Demander le focus à la page parente
function requestFocus() {
  notifyParent('requestFocus', {})
}
</code></pre>
<h2>7) Dimensions adaptatives pour l’intégration</h2>
<p>Gérez les différentes dimensions d’intégration :</p>
<pre><code class="language-js">function handleResize() {
  const width = window.innerWidth
  const height = window.innerHeight
  
  // Adapter l’interface à la taille disponible
  if (width &lt; 400 || height &lt; 300) {
    enableCompactUI()
  } else {
    enableFullUI()
  }
  
  // Redimensionner correctement le jeu
  resizeCanvas(width, height)
}

window.addEventListener('resize', handleResize)
handleResize()
</code></pre>
<h2>8) Indicateur de chargement pour les jeux intégrés</h2>
<p>Affichez immédiatement un élément visuel :</p>
<pre><code class="language-js">// Intégrer directement dans le HTML pour un affichage instantané
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;Chargement...&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
`

// Supprimer l’indicateur lorsque le jeu est prêt
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) SDK propres aux plateformes</h2>
<p>Certaines plateformes disposent de leurs propres API :</p>
<p><strong><a href="http://Itch.io">Itch.io</a> :</strong></p>
<pre><code class="language-js">// Aucun SDK requis, mais postMessage peut servir pour les succès
window.parent.postMessage({ type: 'itch-achievement', data: { id: 'first-win' }}, '*')
</code></pre>
<p><strong>Newgrounds :</strong></p>
<pre><code class="language-js">// Inclure le SDK Newgrounds.io
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">// Signaler que le jeu est prêt
window.parent.postMessage({ type: 'cinevva:ready' }, '*')

// Signaler que le jeu est jouable
window.parent.postMessage({ type: 'cinevva:playable' }, '*')
</code></pre>
<h2>10) Considérations de sécurité</h2>
<pre><code class="language-js">// Valider l’origine des messages pour les opérations sensibles
window.addEventListener('message', (event) =&gt; {
  const trustedOrigins = [
    'https://itch.io',
    'https://cinevva.com',
    'https://yoursite.com'
  ]
  
  if (!trustedOrigins.includes(event.origin)) {
    return // Ignorer les messages non fiables
  }
  
  // Traiter le message...
})

// Ne pas exposer d’opérations sensibles via postMessage
// Autoriser uniquement les commandes figurant sur la liste blanche
const allowedCommands = ['pause', 'resume', 'setVolume', 'mute']

window.addEventListener('message', (event) =&gt; {
  const { type } = event.data
  if (!allowedCommands.includes(type)) return
  
  // Traiter la commande...
})
</code></pre>
<h2>Liste de vérification pour les tests</h2>
<ul>
<li><strong>Tests locaux</strong> : utilisez un serveur HTTP simple, et non <code>file://</code></li>
<li><strong>Origines différentes</strong> : effectuez un test avec une véritable intégration dans une iframe</li>
<li><strong>Permissions</strong> : effectuez un test avec un bac à sable restrictif</li>
<li><strong>Focus</strong> : testez le clavier après avoir cliqué en dehors de l’iframe</li>
<li><strong>Redimensionnement</strong> : testez différentes tailles d’intégration</li>
<li><strong>Mobile</strong> : testez les commandes tactiles dans un contexte d’intégration</li>
</ul>
<pre><code class="language-html">&lt;!-- Page de test de l’intégration --&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;Test d’intégration&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>Articles connexes</h2>
<ul>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/mobile-friendly-web-games">Jeux web adaptés aux appareils mobiles</a></li>
<li><a href="/fr/creators">Pour les créateurs</a></li>
<li><a href="/fr/tutorials/coop-coep-sharedarraybuffer">COOP/COEP et SharedArrayBuffer</a> — les en-têtes interorigines qui affectent l’intégration dans une iframe</li>
<li><a href="/fr/guides/itch-io-launch-guide">Comment lancer votre jeu sur itch.io</a> — <a href="http://itch.io">itch.io</a> intègre nativement les jeux sur navigateur dans des iframes</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe">MDN : élément iframe</a> — référence complète des attributs d’une iframe</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy">MDN : politique d’autorisations</a> — contrôle des fonctionnalités accessibles aux iframes</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage">MDN : API postMessage</a> — communication entre une iframe et sa page parente</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Sauvegarder et charger l’état du jeu avec IndexedDB]]></title>
            <link>https://app.cinevva.com/fr/tutorials/indexeddb-game-saves</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/indexeddb-game-saves</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Sauvegardes de jeu persistantes avec IndexedDB : stockage de la progression, des paramètres et de grandes quantités de données. Des modèles asynchrones qui ne bloquent pas votre jeu.]]></description>
            <content:encoded><![CDATA[<h1>Sauvegarder et charger l’état du jeu avec IndexedDB</h1>
<p>IndexedDB est la meilleure option pour stocker durablement les données d’un jeu dans le navigateur. Il gère de grandes quantités de données, fonctionne hors ligne et ne bloque pas le thread principal.</p>
<h2>1) Pourquoi choisir IndexedDB plutôt que localStorage ?</h2>
<table>
<thead>
<tr>
<th>Fonctionnalité</th>
<th>localStorage</th>
<th>IndexedDB</th>
</tr>
</thead>
<tbody>
<tr>
<td>Limite de stockage</td>
<td>~5 à 10 Mo</td>
<td>50 Mo et plus (souvent des Go)</td>
</tr>
<tr>
<td>Types de données</td>
<td>Chaînes uniquement</td>
<td>Objets, blobs, tableaux</td>
</tr>
<tr>
<td>Asynchrone</td>
<td>Non (bloquant)</td>
<td>Oui</td>
</tr>
<tr>
<td>Requêtes indexées</td>
<td>Non</td>
<td>Oui</td>
</tr>
</tbody>
</table>
<p>Pour les jeux, IndexedDB est presque toujours le bon choix.</p>
<h2>2) Ouvrir une base de données</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) Sauvegarder l’état du jeu</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) Charger l’état du jeu</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) Lister toutes les sauvegardes</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) Supprimer une sauvegarde</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) Une classe GameStorage complète</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) Stocker des données binaires (textures, audio)</h2>
<p>IndexedDB prend en charge les Blobs et les ArrayBuffers :</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) Modèle de sauvegarde automatique</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) Gestion des erreurs et solutions de repli</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) Protéger les sauvegardes contre l’éviction</h2>
<p>La persistance d’IndexedDB n’est pas garantie. Par défaut, une origine utilise un stockage « au mieux », que le navigateur peut évincer lorsque le disque est plein ou, avec Safari/WebKit, après une période sans interaction de l’utilisateur avec votre site. Pour des sauvegardes de jeu, ce sont précisément les données que vous ne voulez pas perdre.</p>
<p>Demandez un stockage persistant afin que le navigateur n’efface pas vos données sans action explicite de l’utilisateur :</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>Les navigateurs décident d’accorder ou non cette autorisation selon des signaux d’engagement, comme la fréquence d’interaction de l’utilisateur avec votre site ou son installation en tant que PWA. Ne supposez donc pas que la demande réussira toujours. Vous pouvez également vérifier l’espace disponible avant d’écrire de grandes sauvegardes ou de mettre des ressources en cache :</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>Ces deux API nécessitent un contexte sécurisé (HTTPS ou localhost).</p>
<h2>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/pwa-offline-games">PWA pour les jeux hors ligne</a></li>
<li><a href="/fr/tutorials/service-workers-caching">Service workers pour la mise en cache des jeux</a></li>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/streaming-asset-loading">Chargement progressif des ressources</a> — utiliser IndexedDB comme cache de ressources</li>
<li><a href="/fr/tutorials/analytics-web-games">Analytique pour les jeux web</a> — suivre les habitudes de sauvegarde et de chargement pour comprendre le comportement des joueurs</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">MDN : API IndexedDB</a> — référence complète de l’API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB">MDN : Utiliser IndexedDB</a> — guide étape par étape</li>
<li><a href="https://github.com/jakearchibald/idb">Bibliothèque idb</a> — un petit wrapper IndexedDB fondé sur les promesses</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Jeux web adaptés aux appareils mobiles]]></title>
            <link>https://app.cinevva.com/fr/tutorials/mobile-friendly-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/mobile-friendly-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Adaptez les jeux web aux appareils mobiles : commandes tactiles, performances, zone d’affichage, batterie et design responsive. Touchez la plus grande plateforme de jeu.]]></description>
            <content:encoded><![CDATA[<h1>Jeux web adaptés aux appareils mobiles</h1>
<p>Le mobile est la plus grande plateforme de jeu. Quelques ajustements suffisent pour que votre jeu web fonctionne parfaitement sur les téléphones et les tablettes.</p>
<h2>1) Configuration de la zone d’affichage</h2>
<p>Empêchez le zoom et assurez une mise à l’échelle correcte :</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) Canvas en plein écran</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>Si vous dimensionnez l’interface en fonction de la hauteur de la zone d’affichage plutôt qu’avec <code>height: 100%</code>, préférez les unités dynamiques de zone d’affichage <code>dvh</code>, <code>svh</code> et <code>lvh</code> à <code>vh</code>. Elles tiennent compte de la barre d’outils rétractable du navigateur mobile, contrairement à <code>100vh</code> (sur iOS, <code>100vh</code> correspond à la hauteur lorsque la barre d’outils est masquée, ce qui peut couper le bas de votre mise en page). Ces unités ont atteint le statut Baseline Widely Available et sont prises en charge dans Chrome/Edge 108+, Firefox 101+ et Safari/iOS Safari 15.4+, soit environ 92 % des utilisateurs.</p>
<h2>3) Gestion des événements tactiles</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>Si votre jeu doit également fonctionner avec une souris ou un stylet, envisagez d’utiliser les événements de pointeur (<code>pointerdown</code>/<code>pointermove</code>/<code>pointerup</code>) plutôt que les événements tactiles. MDN les recommande désormais comme modèle d’entrée unique et indépendant du périphérique : un seul parcours de code gère le tactile, la souris et le stylet, tandis que le multitouch reste disponible grâce à <code>pointerId</code> (l’équivalent de <code>touch.identifier</code> utilisé ci-dessus). Les événements tactiles conviennent toujours parfaitement aux jeux exclusivement tactiles.</p>
<h2>4) Joystick virtuel</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) Boutons tactiles</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) Gestion de l’orientation</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) Optimisation des performances</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) Gestion de la batterie</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) API Fullscreen</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>Une limite à connaître : l’API Fullscreen ne fonctionne pas pour les éléments génériques sur iPhone. Safari sur iOS ne propose le plein écran que pour les éléments vidéo natifs (via la méthode non standard <code>webkitEnterFullscreen</code>). Ainsi, <code>documentElement.requestFullscreen()</code> échouera sur iPhone. Le bloc <code>try/catch</code> ci-dessus gère ce cas proprement : concevez donc votre jeu pour qu’il reste jouable sans véritable plein écran sur iPhone et considérez le plein écran comme une amélioration sur iPad, Android et les ordinateurs où il est pris en charge. L’appel <code>screen.orientation.lock()</code> présente la même limitation sur iPhone ; ne rendez donc pas votre jeu dépendant de cette fonctionnalité.</p>
<h2>10) Liste de contrôle pour les tests</h2>
<p>Avant la publication :</p>
<ul>
<li><strong>Réactivité tactile</strong> — Testez sur de vrais appareils, pas uniquement dans des émulateurs</li>
<li><strong>Différentes tailles d’écran</strong> — Téléphone, tablette, iPad Pro</li>
<li><strong>Les deux orientations</strong> — Ou verrouillez-en une</li>
<li><strong>Appareils d’entrée de gamme</strong> — Testez sur des téléphones âgés de 2 à 3 ans</li>
<li><strong>Réseaux lents</strong> — Testez avec une simulation 3G</li>
<li><strong>Safari sur iOS</strong> — Présente des particularités pour l’audio et le plein écran</li>
<li><strong>Chrome sur Android</strong> — Le navigateur le plus courant</li>
<li><strong>Interruptions</strong> — Appels téléphoniques, notifications</li>
<li><strong>Pression mémoire</strong> — Le jeu récupère-t-il correctement après le passage de l’onglet en arrière-plan ?</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>Articles connexes</h2>
<ul>
<li><a href="/fr/tutorials/game-input-handling">Gestion des commandes de jeu</a></li>
<li><a href="/fr/tutorials/responsive-game-canvas">Canvas de jeu responsive</a></li>
<li><a href="/fr/tutorials/pwa-offline-games">PWA pour les jeux hors ligne</a></li>
<li><a href="/fr/tutorials/web-audio-api-games">API Web Audio pour les jeux</a> — gestion des restrictions audio d’iOS</li>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a> — optimisation des temps de chargement sur les connexions mobiles</li>
<li><a href="/fr/tutorials/iframe-embedding-games">Intégration de jeux par iframe</a> — considérations propres à l’intégration sur mobile</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN : événements tactiles</a> — gestion du multitouch</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API">MDN : API Screen Orientation</a> — verrouillage de l’orientation</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">MDN : API Fullscreen</a> — passage en plein écran sur mobile</li>
<li><a href="https://web.dev/learn/design/">web.dev : design responsive</a> — modèles responsives applicables aux interfaces de jeu</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Rendu du pixel art dans les jeux web]]></title>
            <link>https://app.cinevva.com/fr/tutorials/pixel-art-rendering</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/pixel-art-rendering</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Affichez correctement le pixel art : mise à l’échelle nette, sans flou, palettes de couleurs et animation. Donnez à votre jeu rétro un style maîtrisé.]]></description>
            <content:encoded><![CDATA[<h1>Rendu du pixel art dans les jeux web</h1>
<p>Le pixel art est magnifique lorsqu’il est correctement affiché, mais désastreux lorsqu’il est flou. Ce tutoriel présente les techniques permettant d’obtenir des graphismes en pixels nets et authentiques.</p>
<h2>1) Le problème du flou</h2>
<p>Par défaut, les navigateurs lissent les images lors de leur mise à l’échelle. Pour le pixel art, ce comportement est inadapté :</p>
<pre><code class="language-css">/* Solution CSS */
canvas {
  image-rendering: pixelated;
  image-rendering: crisp-edges; /* Solution de repli pour Firefox */
}
</code></pre>
<h2>2) Paramètres du contexte Canvas</h2>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

// Désactiver le lissage pour obtenir des pixels nets
ctx.imageSmoothingEnabled = false
</code></pre>
<p>Ce paramètre doit être réappliqué après chaque redimensionnement du canvas ou changement de contexte.</p>
<h2>3) Mise à l’échelle par nombres entiers</h2>
<p>Le pixel art est plus réussi avec des facteurs d’échelle entiers (1x, 2x, 3x, 4x) :</p>
<pre><code class="language-js">const GAME_WIDTH = 160
const GAME_HEIGHT = 144 // Résolution de la Game Boy

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) Dessiner les sprites à des positions entières</h2>
<p>Les positions sous-pixelliques provoquent du flou :</p>
<pre><code class="language-js">// Mauvais : provoque du flou
ctx.drawImage(sprite, 10.5, 20.3)

// Bon : pixels nets
ctx.drawImage(sprite, Math.floor(10.5), Math.floor(20.3))

// Ou arrondir à l’entier le plus proche
ctx.drawImage(sprite, Math.round(player.x), Math.round(player.y))
</code></pre>
<h2>5) Animation d’une feuille de sprites</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()
  }
}

// Utilisation
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) Remplacement de palette</h2>
<p>Une technique classique pour recolorer les 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
}

// Créer une variante de l’ennemi
const blueEnemy = createPaletteSwap(
  enemySprite,
  [{ r: 255, g: 0, b: 0 }],    // Rouge
  [{ r: 0, g: 100, b: 255 }]   // Bleu
)
</code></pre>
<h2>7) Rendu d’une carte de tuiles</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) Effets de contour</h2>
<p>Ajoutez des contours aux sprites pour améliorer leur visibilité :</p>
<pre><code class="language-js">function drawWithOutline(ctx, image, x, y, outlineColor = '#000') {
  const offsets = [[-1, 0], [1, 0], [0, -1], [0, 1]]
  
  // Dessiner le contour
  ctx.globalCompositeOperation = 'source-over'
  for (const [ox, oy] of offsets) {
    ctx.drawImage(image, Math.round(x + ox), Math.round(y + oy))
  }
  
  // Appliquer la couleur au contour
  ctx.globalCompositeOperation = 'source-in'
  ctx.fillStyle = outlineColor
  ctx.fillRect(x - 1, y - 1, image.width + 2, image.height + 2)
  
  // Dessiner le sprite par-dessus
  ctx.globalCompositeOperation = 'source-over'
  ctx.drawImage(image, Math.round(x), Math.round(y))
}
</code></pre>
<h2>9) Tremblement de l’écran</h2>
<p>Un effet classique pour renforcer les sensations de jeu :</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),
    }
  }
}

// Dans la fonction de rendu :
function render() {
  const shake = camera.getOffset()
  ctx.save()
  ctx.translate(shake.x, shake.y)
  // Tout dessiner...
  ctx.restore()
}

// Lors d’un impact :
camera.shake(4, 0.2) // Intensité de 4 pixels pendant 0,2 seconde
</code></pre>
<h2>10) Contraintes de palette de couleurs</h2>
<p>Le pixel art authentique utilise des palettes limitées :</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>Articles connexes</h2>
<ul>
<li><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu Canvas 2D</a></li>
<li><a href="/fr/tutorials/responsive-game-canvas">Canvas de jeu responsive</a></li>
<li><a href="/fr/tutorials/streaming-asset-loading">Chargement progressif des ressources</a></li>
<li><a href="/fr/guides/game-assets-guide">Où trouver des ressources de jeu gratuites</a> — feuilles de sprites, packs de pixel art et sources de ressources 2D</li>
<li><a href="/fr/tutorials/text-rendering-games">Rendu du texte dans les jeux</a> — polices bitmap adaptées au style de votre pixel art</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Verrouillage du pointeur pour les jeux FPS]]></title>
            <link>https://app.cinevva.com/fr/tutorials/pointer-lock-fps</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/pointer-lock-fps</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Implémentez la vue à la souris avec l’API Pointer Lock : capture du curseur, gestion des mouvements et création de commandes FPS pour les jeux web.]]></description>
            <content:encoded><![CDATA[<h1>Verrouillage du pointeur pour les jeux FPS</h1>
<p>L’API Pointer Lock capture le curseur de la souris afin d’offrir des commandes de caméra fluides de type FPS. Elle est indispensable pour les jeux de tir à la première et à la troisième personne.</p>
<h2>1) Demander le verrouillage du pointeur</h2>
<p>Le verrouillage du pointeur nécessite une action de l’utilisateur :</p>
<pre><code class="language-js">const canvas = document.getElementById('game')

canvas.addEventListener('click', () =&gt; {
  canvas.requestPointerLock()
})
</code></pre>
<h2>Entrée brute de la souris (désactiver l’accélération)</h2>
<p>Par défaut, le navigateur applique l’accélération de la souris du système d’exploitation à <code>movementX</code>/<code>movementY</code>. Ainsi, un mouvement rapide et un déplacement lent sur la même distance produisent des rotations de caméra différentes. Pour viser dans un FPS, on souhaite généralement l’inverse : une même distance physique doit toujours faire pivoter la caméra du même angle. Demandez des mouvements bruts, sans accélération, en passant <code>unadjustedMovement: true</code> à <code>requestPointerLock()</code> :</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>La version moderne de <code>requestPointerLock()</code> renvoie une Promise qui est résolue en cas de réussite et rejetée en cas d’échec, d’où l’utilisation de <code>try/catch</code> autour de l’appel ci-dessus. Chrome et Edge prennent en charge <code>unadjustedMovement</code> depuis la version 88, et Safari depuis la version 18.4 (mars 2025), qui a également corrigé <code>requestPointerLock</code> afin qu’elle renvoie une Promise. Les anciens navigateurs qui ne renvoient pas de Promise fonctionnent tout de même ici, car <code>await</code> accepte une valeur qui n’est pas une Promise et l’option inconnue est simplement ignorée. Conservez donc toujours l’appel de secours à <code>requestPointerLock()</code> sans option.</p>
<h2>2) Détecter l’état du verrouillage</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) Lire les mouvements de la souris</h2>
<p>Lorsque le pointeur est verrouillé, utilisez <code>movementX</code> et <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) Classe de caméra FPS</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) Déplacement avec les touches WASD</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) Contrôleur FPS complet</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) Réticule</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) Tirer avec les boutons de la souris</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) Réglages de sensibilité</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) Indication pour quitter</h2>
<p>Affichez les instructions permettant de quitter le verrouillage du pointeur :</p>
<pre><code class="language-js">function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '&lt;p&gt;Appuyez sur Échap pour libérer la souris&lt;/p&gt;'
    ui.style.opacity = '0.5'
    setTimeout(() =&gt; ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '&lt;p&gt;Cliquez pour jouer&lt;/p&gt;'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)
</code></pre>
<p>À propos de cette indication : lorsque le joueur appuie sur Échap pour quitter le verrouillage du pointeur, le navigateur exige une nouvelle action de l’utilisateur (un clic) avant d’autoriser un nouveau verrouillage. C’est pourquoi la surcouche « Cliquez pour jouer » est nécessaire. En revanche, si votre propre code appelle <code>document.exitPointerLock()</code> (par exemple pour ouvrir un menu en jeu), aucune nouvelle action n’est nécessaire pour réactiver ensuite le verrouillage. Vous pouvez donc le rétablir par programmation lorsque le menu se ferme. Des pressions répétées sur Échap peuvent également inciter le navigateur à refuser tout nouveau verrouillage jusqu’à ce que l’utilisateur effectue une action plus explicite. N’essayez donc pas de réactiver automatiquement le verrouillage lors de <code>pointerlockchange</code>.</p>
<h2>Considérations relatives aux iframes</h2>
<p>Le verrouillage du pointeur dans les iframes nécessite l’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>Vérifiez si le verrouillage du pointeur est disponible :</p>
<pre><code class="language-js">if (!document.pointerLockElement &amp;&amp; !('requestPointerLock' in canvas)) {
  showMessage('La capture de la souris n’est pas disponible')
}
</code></pre>
<h2>À lire également</h2>
<ul>
<li><a href="/fr/tutorials/game-input-handling">Gestion des entrées de jeu</a></li>
<li><a href="/fr/tutorials/gamepad-api">API Gamepad</a></li>
<li><a href="/fr/tutorials/webgl-fundamentals-for-games">Principes fondamentaux de WebGL</a></li>
<li><a href="/fr/tutorials/webxr-basics">Notions de base de WebXR</a> — commandes VR pour les expériences à la première personne</li>
<li><a href="/fr/tutorials/webgpu-getting-started">Bien démarrer avec WebGPU</a> — rendu GPU moderne pour les jeux FPS</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API">MDN : API Pointer Lock</a> — référence complète de l’API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX">MDN : MouseEvent.movementX</a> — lecture des déplacements relatifs de la souris</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[PWA pour les jeux web hors ligne]]></title>
            <link>https://app.cinevva.com/fr/tutorials/pwa-offline-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/pwa-offline-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Faites fonctionner votre jeu hors ligne avec une PWA : manifeste, service worker, stratégies de mise en cache et invites d’installation. Jouez partout, à tout moment.]]></description>
            <content:encoded><![CDATA[<h1>PWA pour les jeux web hors ligne</h1>
<p>Les Progressive Web Apps permettent aux joueurs d’installer votre jeu et d’y jouer hors ligne. Ce tutoriel explique comment ajouter la prise en charge des PWA à un jeu web.</p>
<h2>1) Manifeste d’application web</h2>
<p>Créez <code>manifest.json</code> :</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;Mon jeu génial&quot;,
  &quot;short_name&quot;: &quot;JeuGénial&quot;,
  &quot;description&quot;: &quot;Un jeu génial auquel vous pouvez jouer hors ligne&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>Ajoutez le lien dans votre 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) Service worker de base</h2>
<p>Créez <code>sw.js</code> :</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) Enregistrer le service worker</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) Priorité au cache avec mise à jour réseau</h2>
<p>Une meilleure approche pour les jeux : chargement rapide et mises à jour en arrière-plan.</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) Cache versionné pour les mises à jour</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) Gérer les mises à jour du jeu</h2>
<p>Informez les joueurs lorsqu’une mise à jour est disponible :</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;Jeu mis à jour ! Actualisez la page pour obtenir la dernière version.&lt;/p&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Actualiser&lt;/button&gt;
  `
  banner.className = 'update-banner'
  document.body.appendChild(banner)
}
</code></pre>
<h2>7) Détecter le mode hors ligne</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) Invite d’installation</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>Sur iOS et iPadOS, l’événement <code>beforeinstallprompt</code> ne se déclenche jamais. Le bouton d’installation ci-dessus ne s’affiche donc que dans les navigateurs Chromium. Les utilisateurs d’iPhone et d’iPad installent l’application en touchant Partager, puis « Sur l’écran d’accueil ». Un changement intervenu en 2026 mérite d’être signalé : à partir de Safari 26 (iOS 26 / iPadOS 26), chaque site ajouté à l’écran d’accueil s’ouvre par défaut comme une application web, avec une option « Ouvrir comme app web » activée par défaut. Il n’existe plus d’exigence d’installabilité sur iOS, même si fournir un manifeste et un service worker offre toujours une bien meilleure expérience hors ligne.</p>
<h2>9) Synchronisation en arrière-plan des classements</h2>
<p>Attention à la compatibilité : l’API Background Sync n’est implémentée que dans les navigateurs Chromium (Chrome, Edge, Opera et Samsung Internet). Firefox et Safari, y compris sur iOS, ne la prennent pas en charge. Détectez donc toujours cette fonctionnalité et prévoyez une solution de repli qui envoie les scores au prochain chargement normal de l’application.</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) Tester la PWA</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; Case à cocher Offline</li>
</ul>
<p><strong>Audit Lighthouse :</strong></p>
<ul>
<li>Exécuter l’audit PWA</li>
<li>Vérifier l’installabilité</li>
<li>Vérifier le fonctionnement hors ligne</li>
</ul>
<p><strong>Test sur un appareil réel :</strong></p>
<ul>
<li>Installer le jeu sur l’écran d’accueil du téléphone</li>
<li>Activer le mode avion</li>
<li>Tester toutes les fonctionnalités hors ligne</li>
</ul>
<h2>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/service-workers-caching">Service workers pour la mise en cache des jeux</a></li>
<li><a href="/fr/tutorials/indexeddb-game-saves">Sauvegardes de jeu avec IndexedDB</a></li>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/mobile-friendly-web-games">Jeux web adaptés aux appareils mobiles</a> — commandes tactiles et gestion de la fenêtre d’affichage pour les jeux PWA</li>
<li><a href="/fr/tutorials/streaming-asset-loading">Chargement progressif des ressources</a> — stratégies de chargement compatibles avec la mise en cache par service worker</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps">MDN : Progressive Web Apps</a> — documentation complète sur les PWA</li>
<li><a href="https://web.dev/learn/pwa/">web.dev : découvrir les PWA</a> — parcours d’apprentissage des PWA proposé par Google</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Manifest">MDN : manifeste d’application web</a> — référence du fichier manifeste</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Canvas de jeu responsive avec une mise à l’échelle correcte]]></title>
            <link>https://app.cinevva.com/fr/tutorials/responsive-game-canvas</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/responsive-game-canvas</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Rendez votre canvas de jeu responsive : mise à l’échelle au pixel près, bandes noires et gestion fluide des événements de redimensionnement.]]></description>
            <content:encoded><![CDATA[<h1>Canvas de jeu responsive</h1>
<p>Un canvas qui ne se redimensionne pas correctement devient flou ou s’affiche mal selon la taille de l’écran. J’ai dû résoudre ce problème plus souvent que je ne saurais le compter, alors laissez-moi vous épargner ces difficultés.</p>
<p><strong>Essayez différents modes de mise à l’échelle :</strong></p>
<ResponsiveDemo />
<h2>Le problème fondamental</h2>
<p>Un canvas possède deux tailles qu’il est facile de confondre. La taille CSS détermine ses dimensions apparentes à l’écran. La résolution interne détermine le nombre de pixels que vous dessinez réellement. Si elles ne correspondent pas, l’image devient floue ou déformée.</p>
<p>Sur un écran Retina, votre CSS peut indiquer 800x600, alors que l’écran possède en réalité 1600x1200 pixels physiques. Si la résolution interne de votre canvas n’est que de 800x600, le navigateur l’agrandit et tout paraît flou.</p>
<h2>Configuration responsive de base</h2>
<p>Voici comment faire en sorte qu’un canvas remplisse la fenêtre tout en restant net :</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>La ligne <code>ctx.scale(dpr, dpr)</code> est l’astuce essentielle. Vous pouvez ensuite dessiner à la position (100, 100), qui s’affichera au bon endroit quelle que soit la valeur du DPR. En interne, le canvas effectue un rendu en 200x200 sur un écran 2x, mais votre code n’a pas besoin de le savoir.</p>
<p>Attention à un piège : <code>ctx.scale</code> est cumulatif. Si vous appelez plusieurs fois la fonction de redimensionnement, la mise à l’échelle continuera d’augmenter. Réinitialisez d’abord la transformation avec <code>ctx.setTransform(1, 0, 0, 1, 0, 0)</code> avant d’appliquer la mise à l’échelle, ou ne l’appliquez qu’une seule fois.</p>
<h2>Format d’image fixe avec bandes noires</h2>
<p>De nombreux jeux nécessitent un format d’image précis. Vous ne voulez pas que votre jeu en 16:9 soit étiré pour remplir un moniteur ultralarge en 21:9. L’ajout de bandes noires permet de préserver les proportions.</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>Pour que les bandes noires soient visibles, définissez l’arrière-plan du corps de la page en noir :</p>
<pre><code class="language-css">body {
  margin: 0;
  background: #000;
  overflow: hidden;
}
</code></pre>
<h2>Mise à l’échelle entière pour le pixel art</h2>
<p>Le pixel art reste net avec des facteurs d’échelle entiers (1x, 2x, 3x), mais devient flou avec des facteurs fractionnaires comme 2,7x. Même avec <code>image-rendering: pixelated</code>, une échelle de 2,5x signifie que certains pixels occupent 2 pixels à l’écran et d’autres 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>En contrepartie, votre jeu ne remplira pas exactement l’écran. Sur un écran de 1920x1080 avec un jeu en 320x180, vous obtenez une mise à l’échelle 5x (1600x900), avec des bandes noires sur les bords. Certains joueurs préfèrent ce résultat ; d’autres souhaitent que le jeu remplisse tout l’écran, même s’il devient légèrement flou.</p>
<h2>Mise à l’échelle d’un canvas WebGL</h2>
<p>Avec WebGL, vous devez mettre à jour la zone d’affichage en plus de la taille du canvas :</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>Appelez cette fonction au début de votre boucle de rendu, et pas uniquement lors des événements de redimensionnement. Certains navigateurs modifient le DPR lorsqu’une fenêtre passe d’un moniteur à un autre, et vous devez pouvoir détecter ce changement.</p>
<h2>Temporisation des événements de redimensionnement</h2>
<p>Les événements de redimensionnement se déclenchent très rapidement lorsque l’utilisateur fait glisser le bord de la fenêtre. Si votre fonction de redimensionnement est coûteuse, l’affichage manquera de fluidité.</p>
<pre><code class="language-js">let resizeTimeout
window.addEventListener('resize', () =&gt; {
  clearTimeout(resizeTimeout)
  resizeTimeout = setTimeout(resize, 100)
})
</code></pre>
<p>Le délai de 100 ms signifie que le redimensionnement ne s’effectue qu’une fois que l’utilisateur a cessé de faire glisser la fenêtre. Pour une gestion plus fluide pendant le déplacement, utilisez plutôt ResizeObserver :</p>
<pre><code class="language-js">const observer = new ResizeObserver(entries =&gt; {
  resize()
})
observer.observe(document.body)
</code></pre>
<p>ResizeObserver se déclenche moins fréquemment et bénéficie d’une meilleure optimisation par le navigateur.</p>
<h2>Plein écran</h2>
<p>L’API Fullscreen permet d’occuper la totalité de l’écran :</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>Vous devez appeler <code>requestFullscreen</code> en réponse à une action de l’utilisateur, comme un clic. Sinon, les navigateurs bloquent la requête. Pensez également à redimensionner le canvas lors de l’entrée et de la sortie du mode plein écran, car l’espace disponible change.</p>
<p>Une réserve concernant les plateformes : à la mi-2026, l’API Fullscreen ne permet toujours pas d’afficher un élément en plein écran dans Safari sur iPhone. <code>requestFullscreen</code> fonctionne dans les navigateurs de bureau et sur iPadOS, mais l’iPhone ne le prend pas en charge pour des éléments arbitraires comme un canvas — seul le plein écran vidéo natif fonctionne. Vérifiez la disponibilité de cette fonctionnalité avec <code>if (canvas.requestFullscreen)</code> et prévoyez une solution de repli élégante, par exemple en dimensionnant le canvas pour remplir la zone d’affichage visible ou en invitant les utilisateurs à ajouter le jeu à leur écran d’accueil sous forme de PWA.</p>
<h2>Assemblage de l’ensemble</h2>
<p>Voici une classe qui gère tous les cas courants :</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>Utilisez-la comme ceci :</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>Pièges courants</h2>
<p>Voici quelques points qui posent souvent problème :</p>
<p><strong>Le canvas s’efface lors du redimensionnement.</strong> Lorsque vous définissez <code>canvas.width</code> ou <code>canvas.height</code>, le canvas s’efface. Si vous ne redessinez pas l’image à chaque frame, vous devez le faire après le redimensionnement.</p>
<p><strong>Le DPR peut changer.</strong> Si l’utilisateur fait passer la fenêtre du navigateur d’un écran Retina à un écran non-Retina, le DPR change. Vérifiez sa valeur dans votre boucle de rendu, et pas uniquement lors du redimensionnement.</p>
<p><strong>Particularités de la zone d’affichage dans Safari sur iOS.</strong> La hauteur de la zone d’affichage change lorsque la barre d’adresse apparaît ou disparaît. Utilisez <code>window.visualViewport</code> pour obtenir des dimensions plus précises sur mobile.</p>
<p><strong>L’état du contexte est réinitialisé.</strong> La modification des dimensions du canvas réinitialise l’état du contexte, notamment les transformations, les styles de remplissage et <code>imageSmoothingEnabled</code>. Réappliquez-les après le redimensionnement.</p>
<h2>Ressources complémentaires</h2>
<p><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu Canvas 2D</a> présente les principes fondamentaux des boucles de mise à jour et de rendu.</p>
<p><a href="/fr/tutorials/pixel-art-rendering">Rendu du pixel art</a> approfondit la création de graphismes en pixels parfaitement nets.</p>
<p><a href="/fr/tutorials/mobile-friendly-web-games">Jeux web adaptés aux mobiles</a> aborde les entrées tactiles et les problèmes liés à la zone d’affichage.</p>
<p><a href="/fr/tutorials/webgl-fundamentals-for-games">Principes fondamentaux de WebGL</a> explique la configuration de la zone d’affichage et du canvas pour les contextes WebGL.</p>
<p><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a> explique comment optimiser la taille du canvas pour améliorer les performances.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Service workers pour la mise en cache des ressources de jeu]]></title>
            <link>https://app.cinevva.com/fr/tutorials/service-workers-caching</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/service-workers-caching</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Mettez en cache les ressources de jeu avec des service workers : stratégies, gestion des versions et mises à jour. Faites en sorte que votre jeu se charge instantanément lors des visites suivantes.]]></description>
            <content:encoded><![CDATA[<h1>Service workers pour la mise en cache des ressources de jeu</h1>
<p>Les service workers vous permettent de contrôler la mise en cache et la distribution des ressources. Pour les jeux, cela signifie des chargements instantanés lors des visites suivantes et une prise en charge hors ligne.</p>
<h2>1) Principes de base des service workers</h2>
<p>Créez <code>sw.js</code> à la racine de votre projet :</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>Enregistrez-le :</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) Stratégie cache-first (idéale pour les ressources de jeu)</h2>
<p>Chargez depuis le cache et ne récupérez la ressource sur le réseau que si elle n'est pas en cache :</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) Prémettre en cache les ressources critiques</h2>
<p>L'événement d'installation est idéal pour la pré-mise en cache :</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) Stratégie network-first pour le contenu dynamique</h2>
<p>Utilisez la stratégie network-first pour les éléments qui changent, comme les classements et les données des joueurs :</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>Renvoyez immédiatement la version en cache et mettez-la à jour en arrière-plan :</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) Gestion des versions et nettoyage du cache</h2>
<p>Supprimez les anciens caches lors de l'activation :</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) Séparer les caches par type</h2>
<p>Organisez les caches pour mieux les gérer :</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) Requêtes de plage pour l'audio et la vidéo</h2>
<p>Une chose à savoir dès le départ : l'API Cache ne stocke pas les réponses partielles. Si une réponse <code>206 Partial Content</code> parvient à <code>cache.put()</code>, celui-ci lève l'erreur <code>TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported</code>. Le modèle à suivre pour les médias mis en cache est donc toujours le même : prémettre en cache, ou récupérer, le fichier complet sous la forme d'une réponse <code>200</code> normale, puis générer vous-même la réponse <code>206</code> en découpant les octets mis en cache lorsqu'une requête arrive avec un en-tête <code>Range</code>. Si vous préférez ne pas l'implémenter vous-même, le plug-in range-requests de Workbox effectue précisément ce découpage lorsqu'il est associé à une stratégie CacheFirst.</p>
<p>Gérez correctement les requêtes de plage, ce qui est nécessaire pour naviguer dans un fichier 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) Notification de mise à jour</h2>
<p>Prévenez les joueurs lorsqu'une nouvelle version est prête :</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;Une nouvelle version est disponible !&lt;/span&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Mettre à jour&lt;/button&gt;
  `
  document.body.appendChild(banner)
}
</code></pre>
<h2>10) Service worker complet pour un jeu</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>Articles associés</h2>
<ul>
<li><a href="/fr/tutorials/pwa-offline-games">PWA pour les jeux hors ligne</a></li>
<li><a href="/fr/tutorials/streaming-asset-loading">Chargement progressif des ressources</a></li>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/web-audio-api-games">API Web Audio pour les jeux</a> — mettre en cache les ressources audio pour jouer hors ligne</li>
<li><a href="/fr/tutorials/indexeddb-game-saves">Sauvegardes de jeu avec IndexedDB</a> — conserver l'état du jeu avec les ressources mises en cache</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">MDN : API Service Worker</a> — référence complète de l'API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Cache">MDN : API Cache</a> — référence de l'interface Cache</li>
<li><a href="https://web.dev/learn/pwa/service-workers/">web.dev : Service workers</a> — stratégies et modèles de mise en cache</li>
<li><a href="https://developer.chrome.com/docs/workbox/">Workbox</a> — bibliothèque de Google proposant des outils pour les service workers</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Un jeu web qui se charge vite (check-list pratique)]]></title>
            <link>https://app.cinevva.com/fr/tutorials/ship-web-game-fast</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/ship-web-game-fast</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Une check-list pragmatique, étape par étape, pour accélérer le chargement des jeux web : budgets, compression, mise en cache, formats de ressources et mesures de performances.]]></description>
            <content:encoded><![CDATA[<h1>Un jeu web qui se charge vite (check-list pratique)</h1>
<p>Si les joueurs ne peuvent pas <strong>commencer à jouer en quelques secondes</strong>, ils s’en vont. Ce tutoriel propose une check-list pratique applicable à presque toutes les piles technologiques de jeux web (Canvas/WebGL/WebGPU, Pixi/Three/PlayCanvas, moteur personnalisé, etc.).</p>
<h2>1) Définissez d’abord des budgets (sinon vous ne pourrez pas optimiser)</h2>
<p>Choisissez des budgets adaptés à l’appareil ciblé. Voici de bonnes valeurs par défaut pour une expérience de « jeu instantané » :</p>
<ul>
<li><strong>JS+CSS transférés</strong> : moins de ~300–500 Ko avec gzip (moins, c’est mieux)</li>
<li><strong>Ressources critiques avant la première interaction</strong> : moins de ~2–5 Mo compressés</li>
<li><strong>Délai avant la première interaction</strong> : moins de ~3–5 secondes sur un appareil mobile de milieu de gamme</li>
</ul>
<p>Notez ces valeurs et considérez-les comme des contrats d’API.</p>
<h2>2) Mesurez les bons éléments</h2>
<p>Dans les outils de développement Chrome :</p>
<ul>
<li>Utilisez <strong>Performance</strong> pour enregistrer une session et examiner les tâches longues.</li>
<li>Utilisez <strong>Network</strong> pour vérifier :
<ul>
<li>la taille transférée par rapport à la taille décodée</li>
<li>les en-têtes de mise en cache</li>
<li>si les ressources sont diffusées progressivement ou arrivent sous la forme d’un seul énorme bloc</li>
</ul>
</li>
</ul>
<p>Pour votre jeu, mesurez :</p>
<ul>
<li>le délai avant que « <strong>le joueur puisse se déplacer</strong> »</li>
<li>le délai avant la « <strong>première image significative</strong> »</li>
<li>la « <strong>latence de la première interaction</strong> » (interaction → image)</li>
</ul>
<p>Votre intuition concernant la « latence de la première interaction » correspond désormais à une métrique standard : <strong>Interaction to Next Paint (INP)</strong> a remplacé First Input Delay parmi les signaux web essentiels en mars 2024. Cette métrique mesure le délai complet entre une interaction et l’affichage suivant pour toutes les interactions, et pas seulement la première. Visez un INP inférieur à 200 ms au 75e centile : c’est un point de référence externe utile pour les mesures de réactivité que vous suivez déjà.</p>
<h2>3) Compressez tout ce que vous pouvez</h2>
<p>Assurez-vous que votre hébergement utilise :</p>
<ul>
<li><strong>Brotli</strong> (<code>br</code>) pour les ressources textuelles (JS/CSS/HTML/WASM), lorsqu’il est disponible</li>
<li><strong>gzip</strong> comme solution de repli</li>
</ul>
<p>Si votre hébergeur et votre CDN le permettent, <strong>Zstandard</strong> (<code>zstd</code>) constitue désormais une troisième option pour <code>Content-Encoding</code>. Il est pris en charge par Chrome et Edge 123+, Firefox 126+ et Safari 26.3+ (soit environ 80 % des utilisateurs) et peut atteindre des taux de compression comparables à ceux de Brotli tout en sollicitant moins le processeur du serveur, ce qui est utile pour les fichiers WASM et JSON volumineux. Les navigateurs n’annoncent <code>Accept-Encoding: zstd</code> que via HTTPS. Conservez donc Brotli et gzip comme solutions de repli pour tous les autres utilisateurs.</p>
<p>Pour les builds WebAssembly, la compression fait souvent toute la différence entre un chargement « instantané » et un jeu qui « ne se charge jamais ».</p>
<h2>4) Ne téléchargez pas le monde entier dès le départ</h2>
<p>Divisez votre jeu en plusieurs parties :</p>
<ul>
<li><strong>Démarrage</strong> : chargeur minimal + contrôles + première scène</li>
<li><strong>Cœur</strong> : principaux systèmes de jeu</li>
<li><strong>Facultatif</strong> : niveaux supplémentaires, éléments cosmétiques, textures haute résolution, pistes audio bonus, etc.</li>
</ul>
<p>Ne chargez le contenu « Facultatif » qu’une fois que :</p>
<ul>
<li>le joueur peut déjà jouer, et/ou</li>
<li>vous savez qu’il souhaite accéder à ce contenu (par exemple, il a atteint un menu ou un niveau)</li>
</ul>
<h2>5) Choisissez les bons formats de ressources</h2>
<p>Pour la plupart des jeux web :</p>
<ul>
<li><strong>Images</strong> : privilégiez <strong>WebP</strong> (ou <strong>AVIF</strong> si vous pouvez accepter un encodage plus lent) pour l’interface et les arrière-plans.</li>
<li><strong>Audio</strong> : privilégiez <strong>Ogg Vorbis</strong> pour un usage général ; testez <strong>AAC</strong> pour les écosystèmes Apple.</li>
<li><strong>Vidéo</strong> : privilégiez <strong>MP4/H.264</strong> pour la compatibilité et <strong>WebM</strong> lorsqu’il est pris en charge et produit des fichiers plus petits.</li>
</ul>
<p>Surveillez le coût du décodage : un « téléchargement plus léger » peut malgré tout être « plus lent à décoder ».</p>
<h2>6) Gardez votre première scène légère et déterministe</h2>
<p>Évitez les éléments suivants dans votre première scène interactive :</p>
<ul>
<li>une compilation massive de shaders</li>
<li>le transfert d’énormes textures</li>
<li>une génération procédurale qui bloque le thread principal</li>
<li>l’analyse JSON synchrone de fichiers de plusieurs mégaoctets</li>
</ul>
<p>Pour les textures en particulier, utilisez <strong>KTX2 avec la supercompression Basis Universal</strong> plutôt que PNG ou JPEG. Les textures restent compressées pour le GPU après leur transfert (avec transcodage en BC7 sur ordinateur ou en ASTC sur mobile au chargement), ce qui réduit la VRAM nécessaire d’un facteur 4 à 8 et rend les transferts bien moins coûteux que le décodage d’un PNG en pleine résolution suivi de l’envoi des pixels bruts au GPU.</p>
<p>Si vous devez effectuer des opérations lourdes, faites-le :</p>
<ul>
<li>progressivement, sur plusieurs images, ou</li>
<li>dans un Worker (lorsque c’est possible)</li>
</ul>
<h2>7) Mettez en cache de manière intensive (mais correcte)</h2>
<p>Utilisez une mise en cache de longue durée pour les ressources adressées par leur contenu :</p>
<ul>
<li><code>Cache-Control: public, max-age=31536000, immutable</code></li>
</ul>
<p>Pour les points d’entrée HTML, conservez une durée de mise en cache courte afin de pouvoir déployer des mises à jour en toute sécurité.</p>
<h2>8) Rendez les échecs rapides et compréhensibles</h2>
<p>En cas de problème, affichez :</p>
<ul>
<li>un message d’erreur clair</li>
<li>un bouton pour réessayer</li>
<li>un lien permettant de signaler le problème</li>
</ul>
<p>Les échecs silencieux détruisent la confiance.</p>
<h2>9) Si vous utilisez des threads Wasm, comprenez COOP/COEP</h2>
<p>Si votre build dépend de <code>SharedArrayBuffer</code> (ce qui est courant avec certaines configurations multithreads Wasm), vous aurez probablement besoin d’en-têtes d’<strong>isolation interorigine</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>Deux options s’offrent à vous pour l’en-tête COEP. <code>require-corp</code> est l’option stricte, mais elle impose à chaque ressource interorigine (textures provenant d’un CDN, scripts tiers) d’envoyer un en-tête <code>Cross-Origin-Resource-Policy</code>, sans quoi elle sera bloquée. <code>Cross-Origin-Embedder-Policy: credentialless</code> permet également d’activer <code>SharedArrayBuffer</code> et l’isolation interorigine, mais charge les ressources interorigine sans identifiants plutôt que de leur imposer une autorisation explicite via CORP. Si vous récupérez des ressources depuis un CDN que vous ne contrôlez pas, <code>credentialless</code> est généralement la solution la moins contraignante.</p>
<p>Effectuez des tests au plus tôt sur votre véritable hébergement, car les en-têtes et les intégrations tierces peuvent provoquer des dysfonctionnements.</p>
<h2>10) Rendez le jeu jouable sans SDK</h2>
<p>Si vous ciblez le processus de création de Cinevva, visez :</p>
<ul>
<li>une URL unique qui démarre de manière fiable</li>
<li>aucune connexion obligatoire pour lancer une démo</li>
<li>des contrôles prévisibles</li>
</ul>
<p>Vous serez alors prêt à distribuer votre jeu sans intégrer de SDK supplémentaire.</p>
<p>À lire également :</p>
<ul>
<li><a href="/fr/creators">Pour les créateurs de jeux</a></li>
<li><a href="/fr/ai-content">Politique relative au contenu généré par l’IA</a></li>
<li><a href="/fr/tutorials/streaming-asset-loading">Chargement progressif des ressources</a> — modèles de chargement progressif pour les jeux volumineux</li>
<li><a href="/fr/tutorials/service-workers-caching">Service workers pour la mise en cache des jeux</a> — mettez les ressources en cache pour des visites ultérieures instantanées</li>
<li><a href="/fr/guides/web-game-engines-comparison">Comparatif des moteurs de jeux web</a> — taille des builds et temps de chargement selon les moteurs</li>
<li><a href="/fr/guides/itch-io-launch-guide">Comment publier votre jeu sur itch.io</a> — optimisez les jeux pour navigateur intégrés à <a href="http://itch.io">itch.io</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Chargement progressif des ressources pour les jeux web]]></title>
            <link>https://app.cinevva.com/fr/tutorials/streaming-asset-loading</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/streaming-asset-loading</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Chargez progressivement les ressources du jeu : priorisation, streaming, mise en cache et affichage de la progression. Maintenez l’intérêt des joueurs pendant le chargement.]]></description>
            <content:encoded><![CDATA[<h1>Chargement progressif des ressources pour les jeux web</h1>
<p>Les joueurs n'attendront pas le téléchargement de 100 Mo. Chargez progressivement les ressources afin qu'ils puissent jouer pendant que le reste se charge en arrière-plan.</p>
<h2>1) La stratégie de chargement</h2>
<p>Répartissez les ressources en plusieurs catégories :</p>
<ol>
<li><strong>Critiques</strong> — Nécessaires pour afficher le premier écran (&lt; 1 Mo)</li>
<li><strong>Gameplay</strong> — Nécessaires pour jouer (&lt; 10 Mo)</li>
<li><strong>Améliorations</strong> — Facultatives (à charger en arrière-plan)</li>
</ol>
<h2>2) Chargeur de ressources de base</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) Chargement avec suivi de la progression</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>Attention : lorsque le serveur compresse la réponse (avec gzip ou brotli, comme le font la plupart des CDN par défaut), <code>Content-Length</code> correspond à la taille compressée, tandis que les fragments lus sont déjà décompressés. Le rapport <code>received / total</code> dépasse alors 100 %. Pour obtenir une barre de progression précise au niveau des octets avec les réponses compressées, servez la ressource sans compression, envoyez votre propre en-tête indiquant la taille décompressée ou utilisez l'événement <code>onprogress</code> de <code>XMLHttpRequest</code>, qui indique la progression réelle du transfert.</p>
<h2>4) Chargement par lots avec progression globale</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
}

// Utilisation
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(`Chargement : ${Math.round(progress * 100)}% (${name})`)
})
</code></pre>
<h2>5) Chargeur avec file d'attente prioritaire</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()
  }
}

// Utilisation
const loader = new PriorityLoader()
loader.add('critical.png', 10)  // Charger en premier
loader.add('optional.png', 1)   // Charger plus tard
</code></pre>
<h2>6) Chargement différé des niveaux</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) {
    // Charger uniquement ce qui est nécessaire pour ce niveau
    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)
    // Les ressources peuvent être récupérées par le ramasse-miettes
  }
}
</code></pre>
<h2>7) Streaming de fichiers volumineux</h2>
<p>Pour les fichiers volumineux (modèles 3D, audio), utilisez le streaming et traitez-les progressivement :</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)
  }
}

// Pour l'audio : utiliser Media Source Extensions
// Pour la 3D : traiter les données du maillage à mesure qu'elles arrivent
</code></pre>
<h2>8) Mise en cache des ressources chargées</h2>
<p>Associez le chargeur à IndexedDB pour une mise en cache persistante :</p>
<pre><code class="language-js">class CachedLoader {
  constructor() {
    this.memCache = new Map()
    this.dbName = 'AssetCache'
  }
  
  async loadImage(url) {
    // Vérifier la mémoire
    if (this.memCache.has(url)) return this.memCache.get(url)
    
    // Vérifier IndexedDB
    const cached = await this.getFromDB(url)
    if (cached) {
      const img = await this.blobToImage(cached)
      this.memCache.set(url, img)
      return img
    }
    
    // Récupérer et mettre en 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)
    })
  }
  
  // Méthodes IndexedDB...
}
</code></pre>
<h2>9) Modèle d'écran de chargement</h2>
<pre><code class="language-js">class LoadingScreen {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.progress = 0
    this.message = 'Chargement...'
  }
  
  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)
    
    // Barre de progression
    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)
    
    // Texte
    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) Bonnes pratiques</h2>
<ul>
<li><strong>Affichez immédiatement quelque chose</strong> — même une image statique</li>
<li><strong>Chargez d'abord ce qui est visible</strong> — les textures de la vue actuelle</li>
<li><strong>Utilisez des espaces réservés</strong> — des images en basse résolution remplacées ensuite</li>
<li><strong>Préchargez le niveau suivant</strong> — pendant que le joueur joue encore</li>
<li><strong>Gérez les échecs sans interrompre l'expérience</strong> — nouvelles tentatives et solutions de repli</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>Articles connexes</h2>
<ul>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/service-workers-caching">Service workers pour la mise en cache des jeux</a></li>
<li><a href="/fr/tutorials/indexeddb-game-saves">Sauvegardes de jeu avec IndexedDB</a></li>
<li><a href="/fr/tutorials/web-workers-game-logic">Web Workers pour la logique de jeu</a> — décodez et traitez les ressources en dehors du thread principal</li>
<li><a href="/fr/guides/game-assets-guide">Où trouver des ressources de jeu gratuites</a> — sources de modèles 3D, de textures et de contenus audio à diffuser en streaming</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">MDN : API Fetch</a> — réponses en streaming et ReadableStream</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream">MDN : ReadableStream</a> — traitement des fragments de données diffusés en streaming</li>
<li><a href="https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html">Spécification glTF 2.0</a> — le format standard pour la diffusion en streaming de ressources 3D</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Rendu de texte dans les jeux web]]></title>
            <link>https://app.cinevva.com/fr/tutorials/text-rendering-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/text-rendering-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Affichez du texte dans les jeux : texte Canvas, polices bitmap, polices SDF et performances. Des simples libellés aux systèmes de dialogue.]]></description>
            <content:encoded><![CDATA[<h1>Rendu de texte dans les jeux web</h1>
<p>Le texte est omniprésent dans les jeux : scores, dialogues, menus, nombres de dégâts. Ce tutoriel présente différentes approches et explique quand utiliser chacune d’elles.</p>
<h2>1) Principes de base du texte Canvas</h2>
<p>L’approche la plus simple :</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>Si vous avez besoin d’un espacement plus serré ou plus large pour les titres et les libellés de l’interface, le contexte Canvas prend désormais directement en charge <code>letterSpacing</code> et <code>wordSpacing</code>. Ces deux propriétés acceptent des chaînes de longueur CSS et ont atteint le niveau Baseline commun à tous les navigateurs en 2025. Il n’est donc plus nécessaire de dessiner les glyphes un par un pour simuler le crénage :</p>
<pre><code class="language-js">ctx.font = '24px sans-serif'
ctx.letterSpacing = '2px'
ctx.wordSpacing = '6px'
ctx.fillText('FIN DE PARTIE', 100, 100)
</code></pre>
<p>Réinitialisez-les à <code>'0px'</code> lorsque vous avez terminé, car elles restent définies dans le contexte, comme <code>font</code> et <code>fillStyle</code>.</p>
<h2>2) Texte avec contour</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, 'FIN DE PARTIE', 400, 300, '#fff', '#000', 3)
</code></pre>
<h2>3) Texte avec ombre</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'
  
  // Ombre
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillText(text, x + shadowOffset, y + shadowOffset)
  
  // Texte principal
  ctx.fillStyle = color
  ctx.fillText(text, x, y)
}
</code></pre>
<h2>4) Rendu avec une police bitmap</h2>
<p>Pour les jeux en pixel art ou pour obtenir un rendu homogène sur toutes les plateformes :</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
  }
}

// Utilisation
const font = new BitmapFont(fontImage, 8, 8, 
  'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?-+:; '
)
font.draw(ctx, 'SCORE : 1000', 10, 10, 2)
</code></pre>
<h2>5) Texte pré-rendu pour de meilleures performances</h2>
<p>Pour du texte statique, effectuez le rendu une seule fois, puis réutilisez-le :</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
}

// Créer une seule fois
const titleImage = createTextImage('MON JEU', '48px Impact', '#fff')

// Dessiner de nombreuses fois (rapide)
ctx.drawImage(titleImage, x, y)
</code></pre>
<h2>6) Effet machine à écrire</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) Texte avec retour à la ligne automatique</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) Système de boîte de dialogue</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) {
    // Arrière-plan de la boîte
    ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'
    ctx.fillRect(this.x, this.y, this.width, this.height)
    
    // Bordure
    ctx.strokeStyle = '#fff'
    ctx.lineWidth = 2
    ctx.strokeRect(this.x, this.y, this.width, this.height)
    
    // Texte
    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
      )
    }
    
    // Indicateur de continuation
    if (this.isComplete()) {
      ctx.fillText('▼', this.x + this.width - 24, this.y + this.height - 24)
    }
  }
}
</code></pre>
<h2>9) Nombres de dégâts flottants</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
  }
}

// Gestionnaire
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) Texte WebGL (polices SDF)</h2>
<p>Pour les jeux WebGL, les polices à champ de distance signé (SDF) conservent un rendu net à toutes les échelles :</p>
<pre><code class="language-js">// Fragment shader SDF
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);
  }
`

// Des bibliothèques comme msdf-bmfont-xml peuvent générer des atlas de polices SDF
</code></pre>
<p>Pour les scènes three.js en particulier, <code>troika-three-text</code> est une bonne option. Au lieu de précalculer un atlas, cette bibliothèque analyse les fichiers <code>.ttf</code>/<code>.otf</code>/<code>.woff</code> et génère les glyphes SDF à la volée dans un web worker. Elle gère également le crénage, les ligatures et les systèmes d’écriture de droite à gauche.</p>
<p>Pour la plupart des jeux Canvas 2D, les polices bitmap ou le texte Canvas pré-rendu fonctionnent bien.</p>
<h2>Articles connexes</h2>
<ul>
<li><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu Canvas 2D</a></li>
<li><a href="/fr/tutorials/pixel-art-rendering">Rendu du pixel art</a></li>
<li><a href="/fr/tutorials/webgl-fundamentals-for-games">Principes fondamentaux de WebGL</a></li>
<li><a href="/fr/tutorials/responsive-game-canvas">Canvas de jeu adaptatif</a> — mise à l’échelle du texte selon la résolution du canvas et la densité de pixels</li>
<li><a href="/fr/guides/game-assets-guide">Où trouver des ressources de jeu gratuites</a> — sources de polices et de ressources d’interface utilisateur</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Tone.js pour l’audio de jeu]]></title>
            <link>https://app.cinevva.com/fr/tutorials/tone-js-game-audio</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/tone-js-game-audio</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Créez des sons de jeu satisfaisants avec Tone.js : effets sonores synthétisés, musique dynamique et audio réactif qui s’adapte au gameplay.]]></description>
            <content:encoded><![CDATA[<h1>Tone.js pour l’audio de jeu</h1>
<p>Tone.js s’appuie sur l’API Web Audio et facilite l’utilisation de sons synthétisés. Au lieu de charger des fichiers audio, vous générez les sons par programmation. Vous bénéficiez ainsi d’un temps de chargement nul, de variations infinies et de sons capables de réagir au gameplay en temps réel.</p>
<p><strong>Essayez (cliquez sur les boutons pour écouter des sons de jeu synthétisés) :</strong></p>
<ToneDemo />
<h2>Pourquoi utiliser Tone.js plutôt que des fichiers audio</h2>
<p>Les fichiers audio conviennent très bien à la musique et aux sons complexes. Mais pour les effets sonores de jeu, la synthèse présente de réels avantages. Vous pouvez faire varier la hauteur et le timing à chaque lecture afin que les sons ne paraissent pas répétitifs. Vous pouvez générer des sons à la volée en fonction de l’état du jeu. Et vous évitez entièrement les requêtes réseau.</p>
<p>L’inconvénient est que l’audio synthétisé sollicite davantage le processeur que la lecture d’un fichier. Ce n’est pas un problème pour les jeux simples. Pour les jeux complexes, vous pouvez combiner les deux approches.</p>
<h2>Installation</h2>
<p>Chargez Tone.js depuis un CDN ou avec 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>Ou avec 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>Le problème de la lecture automatique</h2>
<p>Les navigateurs bloquent l’audio tant que l’utilisateur n’a pas interagi avec la page. Tone.js gère cette contrainte, mais vous devez appeler <code>Tone.start()</code> lors d’une action de l’utilisateur :</p>
<pre><code class="language-js">document.addEventListener('click', async () =&gt; {
  await Tone.start()
  console.log('Audio ready')
}, { once: true })
</code></pre>
<p>Sans cela, votre AudioContext reste suspendu et aucun son n’est joué.</p>
<h2>Votre premier synthétiseur</h2>
<p>Un synthétiseur génère du son. Le plus simple est <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> joue une note pendant une durée donnée. <code>'C4'</code> correspond au do central. <code>'8n'</code> correspond à une croche au tempo actuel.</p>
<p>Le synthétiseur possède un oscillateur (la source sonore) et une enveloppe (la manière dont le son apparaît et disparaît progressivement). Vous pouvez personnaliser les deux :</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>Les types d’oscillateurs comprennent <code>sine</code> (pur, doux), <code>triangle</code> (feutré), <code>sawtooth</code> (bourdonnant) et <code>square</code> (creux, rétro).</p>
<h2>Créer un système d’effets sonores</h2>
<p>Pour un jeu, vous aurez besoin de plusieurs synthétiseurs adaptés à différents types de sons. Voici une structure qui fonctionne bien :</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> permet de jouer plusieurs notes simultanément. Un <code>Synth</code> classique est monophonique.</p>
<h2>Exemples de sons de jeu</h2>
<p>Voici quelques sons courants dans les jeux et la manière de les créer :</p>
<p><strong>Ramassage d’une pièce</strong> — des notes ascendantes procurent un sentiment de récompense :</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>Saut</strong> — un balayage rapide vers les aigus :</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>Coup/dégâts</strong> — une descente accompagnée de bruit :</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>Mort</strong> — des notes descendantes et mélancoliques :</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>Ajouter des effets</h2>
<p>Les effets enrichissent les sons. Enchaînez-les entre le synthétiseur et la sortie :</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>Le filtre passe-bas atténue les hautes fréquences agressives. La réverbération ajoute une sensation d’espace. Le gain contrôle le volume.</p>
<h2>Limiter la fréquence de déclenchement</h2>
<p>Si votre jeu déclenche rapidement de nombreux sons, ceux-ci peuvent se chevaucher et créer une bouillie sonore. Ajoutez une limitation de fréquence :</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>Musique de fond dynamique</h2>
<p>Pour créer une musique qui réagit au gameplay, utilisez un système basé sur le rythme :</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>L’intensité de la musique évolue en fonction de ce qui se passe dans le jeu.</p>
<h2>Percussions avec MembraneSynth et NoiseSynth</h2>
<p>Pour les percussions, utilisez des synthétiseurs spécialisés :</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>Nettoyage</h2>
<p>Libérez les synthétiseurs lorsque vous n’en avez plus besoin afin de récupérer les ressources :</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>Erreurs courantes</h2>
<p><strong>Ne pas attendre Tone.start()</strong> — aucun son ne sera joué tant que le contexte n’aura pas été démarré à la suite d’une interaction de l’utilisateur.</p>
<p><strong>Créer des synthétiseurs pour chaque son</strong> — créez-les une seule fois lors de l’initialisation, puis réutilisez-les. En créer de nouveaux à chaque fois provoque des fuites de mémoire.</p>
<p><strong>Trop de sons qui se chevauchent</strong> — utilisez la limitation de fréquence et la déduplication pour éviter la bouillie sonore.</p>
<p><strong>Fréquences agressives</strong> — utilisez des filtres passe-bas et des types d’oscillateurs plus doux (<code>sine</code>, <code>triangle</code>) pour créer des sons qui n’agaceront pas les joueurs.</p>
<h2>Ressources supplémentaires</h2>
<p><a href="/fr/tutorials/web-audio-api-games">L’API Web Audio pour les jeux</a> présente l’API sous-jacente et le chargement de fichiers audio.</p>
<p><a href="https://tonejs.github.io/">La documentation de Tone.js</a> contient la référence complète de l’API.</p>
<p><a href="/fr/guides/game-assets-guide">Où trouver des ressources de jeu gratuites</a> présente notamment des fournisseurs de sons et d’effets sonores comme Freesound.</p>
<p><a href="/fr/tutorials/mobile-friendly-web-games">Jeux web adaptés aux appareils mobiles</a> aborde les restrictions audio d’iOS et les méthodes de déverrouillage de l’audio par interaction tactile.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web Audio API pour les jeux]]></title>
            <link>https://app.cinevva.com/fr/tutorials/web-audio-api-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/web-audio-api-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Implémentez l’audio de jeu avec la Web Audio API : effets sonores, musique, audio spatial et mixage. Des méthodes pratiques qui fonctionnent.]]></description>
            <content:encoded><![CDATA[<h1>Web Audio API pour les jeux</h1>
<p>Un bon rendu sonore transforme un jeu. Un jeu silencieux semble sans vie. La Web Audio API permet de produire des effets sonores à faible latence, de lire de la musique et même de créer un son spatial 3D, le tout dans le navigateur.</p>
<p><strong>Essayez-la (cliquez sur un bouton pour écouter des sons synthétisés) :</strong></p>
<AudioDemo />
<h2>Le problème de la lecture automatique</h2>
<p>Avant toute chose, vous devez comprendre les restrictions liées à la lecture automatique. Les navigateurs bloquent le son jusqu’à ce que l’utilisateur interagisse avec votre page. C’est une bonne chose — personne ne veut qu’un site diffuse soudainement du son au chargement — mais cela signifie que vous devez initialiser l’audio en réponse à un clic, un appui ou une pression sur une touche.</p>
<pre><code class="language-js">let audioCtx = null

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

// Initialiser lors de la première interaction
document.addEventListener('click', () =&gt; initAudio(), { once: true })
document.addEventListener('keydown', () =&gt; initAudio(), { once: true })
</code></pre>
<p>Si vous essayez de lire du son avant cela, l’opération échoue silencieusement ou l’AudioContext reste suspendu. Ce comportement prend de nombreux développeurs au dépourvu.</p>
<h2>Chargement des sons</h2>
<p>Les fichiers audio doivent être décodés avant leur lecture. Récupérez le fichier, décodez-le et conservez le tampon pour le réutiliser :</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>Le décodage prend du temps : chargez donc tous vos sons pendant un écran de chargement, et non au moment où vous devez les lire.</p>
<h2>Lecture des sons</h2>
<p>Pour lire un son, créez une source de tampon, connectez-la à un nœud de gain pour contrôler le volume, puis lancez-la :</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>Les sources de tampon sont à usage unique. Une fois la lecture d’une source terminée, vous ne pouvez pas la redémarrer. Créez-en une nouvelle à chaque lecture. Cela peut sembler inefficace, mais c’est ainsi que fonctionne l’API, et les navigateurs sont optimisés pour ce modèle.</p>
<h2>Un gestionnaire de sons</h2>
<p>Pour un véritable jeu, regroupez toutes ces fonctionnalités dans une classe de gestion :</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>Le nœud de gain principal vous permet d’implémenter un curseur de volume global. Tous les sons passent par ce nœud.</p>
<h2>Musique de fond</h2>
<p>La musique doit pouvoir être lue en boucle, arrêtée ou atténuée progressivement :</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>La méthode <code>linearRampToValueAtTime</code> permet d’obtenir des fondus fluides plutôt que des coupures brusques.</p>
<h2>Audio spatial</h2>
<p>Pour les jeux en 3D, vous pouvez positionner les sons dans l’espace. Leur volume diminue à mesure qu’ils s’éloignent de l’auditeur :</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>Appelez <code>updateListener</code> à chaque image avec la position de la caméra ou du joueur. Le panoramique ajuste automatiquement le volume et la répartition stéréo en fonction de la distance et de la direction.</p>
<h2>Sons joués en rafale</h2>
<p>Pour les sons joués plusieurs fois par seconde, comme les tirs ou les bruits de pas, il faut éviter que les instances qui se chevauchent s’accumulent. Un simple système de pool peut vous y aider :</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>Le pool limite le nombre d’instances d’un même son pouvant être lues simultanément. Lorsque la limite est atteinte, l’instance la plus ancienne est remplacée.</p>
<h2>Formats audio</h2>
<p>Certains formats sont plus adaptés que d’autres selon la situation :</p>
<p>Le MP3 fonctionne partout et convient bien à la musique. OGG Vorbis offre une meilleure qualité à taille de fichier égale. Safari a longtemps été le dernier navigateur à ne pas le prendre en charge, mais la lecture native d’Ogg Vorbis a été ajoutée dans Safari 18.4 : tous les principaux navigateurs actuels le prennent donc désormais en charge. Prévoyez tout de même une solution de repli en MP3 si vous souhaitez prendre en charge les anciennes versions de Safari. AAC/M4A fonctionne bien sur les appareils Apple. WAV est non compressé et volumineux, mais son décodage est instantané, ce qui convient aux effets sonores courts lorsque la taille du fichier n’est pas un problème. WebM/Opus offre le meilleur rapport qualité-taille, mais ne fonctionne que dans les navigateurs modernes.</p>
<p>Pour une compatibilité maximale, prévoyez des formats de repli :</p>
<pre><code class="language-js">const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'
</code></pre>
<h2>Astuces utiles</h2>
<p>La <strong>variation de hauteur</strong> rend les sons répétés moins robotiques :</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>Chaque coup de feu ou bruit de pas produit ainsi un son légèrement différent.</p>
<p>Le <strong>ducking</strong> réduit le volume de la musique pendant les dialogues ou les sons importants :</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>Pièges sur iOS</h2>
<p>iOS impose des restrictions audio plus strictes que les autres plateformes. L’AudioContext doit être créé et réactivé pendant un geste de l’utilisateur. Certaines versions d’iOS exigent également que vous lisiez un son, même silencieux, pendant ce geste, plutôt que de simplement créer le contexte. Si l’audio fonctionne sur ordinateur, mais pas sur iOS, c’est probablement la raison.</p>
<h2>Ressources complémentaires</h2>
<p><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a> aborde la compression des ressources, y compris des fichiers audio.</p>
<p><a href="/fr/tutorials/mobile-friendly-web-games">Jeux web adaptés aux appareils mobiles</a> présente davantage de détails sur les problèmes propres à iOS.</p>
<p><a href="/fr/tutorials/pwa-offline-games">PWA pour les jeux hors ligne</a> explique comment mettre en cache les fichiers audio pour jouer hors ligne.</p>
<p><a href="/fr/tutorials/tone-js-game-audio">Tone.js pour l’audio de jeu</a> traite de la génération procédurale de sons et des systèmes musicaux à l’aide d’une bibliothèque de plus haut niveau.</p>
<p><a href="/fr/guides/game-assets-guide">Où trouver des ressources de jeu gratuites</a> répertorie des sources gratuites d’effets sonores et de musique, comme Freesound et Poly Haven.</p>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API">MDN : Web Audio API</a> — référence complète de l’API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioContext">MDN : AudioContext</a> — création et gestion des contextes audio</li>
<li><a href="https://www.w3.org/TR/webaudio/">Spécification de la Web Audio API</a> — spécification du W3C</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web Workers pour la logique de jeu]]></title>
            <link>https://app.cinevva.com/fr/tutorials/web-workers-game-logic</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/web-workers-game-logic</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Déchargez les calculs intensifs vers les Web Workers : physique, recherche de chemin, IA et génération procédurale sans bloquer le thread principal.]]></description>
            <content:encoded><![CDATA[<h1>Web Workers pour la logique de jeu</h1>
<p>Les Web Workers permettent d’exécuter du JavaScript dans un thread en arrière-plan. Pour les jeux, cela signifie que la physique, l’IA et la génération procédurale peuvent s’exécuter sans provoquer de chutes de framerate.</p>
<h2>1) Quand utiliser des Workers</h2>
<p>Cas adaptés :</p>
<ul>
<li>Simulation physique</li>
<li>Recherche de chemin (A*, navmesh)</li>
<li>Prise de décision par l’IA</li>
<li>Génération procédurale</li>
<li>Traitement des ressources (manipulation d’images, compression)</li>
<li>Calculs mathématiques complexes (FFT, détection de collisions)</li>
</ul>
<p>Cas moins adaptés :</p>
<ul>
<li>Rendu (les Workers ne peuvent pas accéder directement au DOM ou au Canvas)</li>
<li>Très petites tâches (le surcoût lié au thread n’en vaut pas la peine)</li>
</ul>
<h2>2) Créer un Worker simple</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) Workers intégrés au code (sans fichier séparé)</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 sous forme de modules ES</h2>
<p>Vous pouvez écrire le code du worker sous forme de module ES et y utiliser <code>import</code> au lieu de <code>importScripts</code>. Transmettez <code>{ type: 'module' }</code> au constructeur :</p>
<pre><code class="language-js">const worker = new Worker('physics-worker.js', { type: 'module' })
</code></pre>
<p>Dans le worker, vous pouvez ensuite utiliser <code>import</code> pour les utilitaires partagés de mathématiques, de collisions ou d’IA, comme dans le thread principal, afin de ne pas dupliquer la logique du jeu. Les workers de type module sont pris en charge par Chrome et Edge 80+, Safari 15+ et Firefox 114+.</p>
<h2>5) Exemple de Worker pour la physique</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) Objets transférables pour améliorer les performances</h2>
<p>Les grandes quantités de données peuvent être transférées sans copie :</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) Worker de recherche de chemin</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) Pool de Workers pour les tâches parallèles</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 pour la synchronisation en temps réel</h2>
<p>Lorsque l’isolation entre origines est activée, vous pouvez partager la mémoire :</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 pour les Workers</h2>
<p>Effectuez le rendu dans un Worker. OffscreenCanvas est désormais largement disponible selon le référentiel Baseline dans Chrome, Edge, Firefox et Safari (Safari l’a pris en charge à partir de la version 17.0 sur macOS et 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) Gestion des erreurs</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>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/coop-coep-sharedarraybuffer">Activer les threads Wasm (SharedArrayBuffer)</a></li>
<li><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu avec Canvas 2D</a></li>
<li><a href="/fr/tutorials/game-physics-libraries">Bibliothèques de physique pour les jeux</a> — exécuter Rapier ou Cannon-es dans un Worker</li>
<li><a href="/fr/tutorials/streaming-asset-loading">Chargement progressif des ressources</a> — décoder les ressources en dehors du thread principal</li>
<li><a href="/fr/guides/web-games-stack-2026">Stack technique des jeux web en 2026</a> — comment les Workers s’intègrent à la stack WebGL/WebGPU/Wasm</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API">MDN : API Web Workers</a> — référence complète de l’API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">MDN : utiliser les Web Workers</a> — guide étape par étape</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects">MDN : objets transférables</a> — transfert de données sans copie</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">MDN : SharedArrayBuffer</a> — mémoire partagée entre les threads</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Principes fondamentaux de WebGL pour les développeurs de jeux]]></title>
            <link>https://app.cinevva.com/fr/tutorials/webgl-fundamentals-for-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/webgl-fundamentals-for-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Découvrez les bases de WebGL pour les jeux : shaders, tampons, textures et boucle de rendu. Du code pratique à copier et à adapter.]]></description>
            <content:encoded><![CDATA[<h1>Principes fondamentaux de WebGL pour les développeurs de jeux</h1>
<p>WebGL vous donne un accès direct au GPU dans le navigateur. C’est la base des jeux web en 3D, ainsi que des jeux 2D performants. Ce tutoriel couvre les notions essentielles.</p>
<h2>1) Obtenir un contexte WebGL</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>Privilégiez <code>webgl2</code> lorsqu’il est disponible : il est largement pris en charge et offre de meilleures fonctionnalités.</p>
<h2>2) Le pipeline de rendu (simplifié)</h2>
<ol>
<li>Le <strong>vertex shader</strong> s’exécute une fois par sommet. Il positionne votre géométrie.</li>
<li>Le <strong>fragment shader</strong> s’exécute une fois par pixel. Il colore le résultat.</li>
<li>Les tampons contiennent les données (positions, coordonnées UV, couleurs).</li>
<li>Les textures sont des images échantillonnées par les shaders.</li>
</ol>
<h2>3) Une paire minimale de shaders</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) Compiler et lier les shaders</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) Créer des tampons</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) Relier les tampons aux shaders</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) La boucle de jeu</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) Charger des textures</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) Pièges courants</h2>
<ul>
<li>Les <strong>textures dont les dimensions sont des puissances de deux</strong> ne sont pas strictement obligatoires dans WebGL 2, mais les mipmaps fonctionnent mieux avec elles.</li>
<li><strong>Perte du contexte</strong> : gérez les événements <code>webglcontextlost</code> — les navigateurs mobiles libèrent les contextes lorsqu’ils manquent de mémoire.</li>
<li>Les <strong>textures en virgule flottante</strong> nécessitent de vérifier la présence d’extensions (<code>EXT_color_buffer_float</code>).</li>
</ul>
<h2>10) Quand utiliser une bibliothèque</h2>
<p>WebGL brut est verbeux. Pour la plupart des jeux, envisagez :</p>
<ul>
<li><strong>Three.js</strong> — solution 3D complète</li>
<li><strong>PixiJS</strong> — rendu 2D rapide</li>
<li><strong>PlayCanvas</strong> — moteur de jeu avec éditeur</li>
</ul>
<p>Utilisez WebGL brut lorsque vous avez besoin :</p>
<ul>
<li>d’un contrôle maximal</li>
<li>d’un moteur de rendu personnalisé</li>
<li>de comprendre comment les choses fonctionnent réellement</li>
</ul>
<p>Une remarque pour 2026 : WebGPU est désormais activé par défaut dans tous les principaux navigateurs (Chrome et Edge depuis la version 113, Firefox 141 sous Windows et 145 sous macOS, ainsi que Safari sur iOS 26, iPadOS 26 et macOS Tahoe 26). Les principes fondamentaux présentés ici restent valables, puisque des bibliothèques comme Three.js utilisent leur moteur de rendu WebGPU avec un repli automatique vers WebGL 2. WebGL 2 demeure donc la référence fiable. Si vous commencez aujourd’hui un nouveau moteur de rendu à partir de zéro, lisez le tutoriel WebGPU en parallèle de celui-ci avant de choisir définitivement une API.</p>
<h2>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/webgpu-getting-started">WebGPU pour les jeux</a></li>
<li><a href="/fr/tutorials/canvas-2d-game-loop">Boucle de jeu avec Canvas 2D</a></li>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/guides/web-games-stack-2026">Stack technique des jeux web en 2026</a> — quand choisir WebGL, WebGPU ou Wasm</li>
<li><a href="/fr/tutorials/game-physics-libraries">Bibliothèques de physique pour les jeux</a> — moteurs physiques dont le rendu passe par WebGL</li>
<li><a href="/fr/guides/web-game-engines-comparison">Comparatif des moteurs de jeux web</a> — Three.js, PixiJS, PlayCanvas et les autres</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API">MDN : API WebGL</a> — référence complète de l’API</li>
<li><a href="https://webgl2fundamentals.org/">Principes fondamentaux de WebGL2</a> — série de tutoriels approfondis sur WebGL2</li>
<li><a href="https://thebookofshaders.com/">The Book of Shaders</a> — guide interactif des fragment shaders GLSL</li>
<li><a href="https://threejs.org/docs/">Documentation de Three.js</a> — le framework WebGL le plus populaire</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bien démarrer avec WebGPU pour les développeurs de jeux]]></title>
            <link>https://app.cinevva.com/fr/tutorials/webgpu-getting-started</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/webgpu-getting-started</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Bien démarrer avec WebGPU : configuration du périphérique, pipelines, shaders et rendu. Une API GPU moderne pour la prochaine génération de jeux web.]]></description>
            <content:encoded><![CDATA[<h1>Bien démarrer avec WebGPU pour les développeurs de jeux</h1>
<p>WebGPU succède à WebGL. Il est de plus bas niveau, plus explicite et conçu pour les GPU modernes. Si vous démarrez un nouveau projet 3D en 2026, WebGPU mérite d'être appris.</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">Tutoriel WebGPU : 10 projets couvrant toutes les étapes, de la configuration au rendu 3D avec éclairage et animation</p>
<h2>1) Vérifier la compatibilité du navigateur</h2>
<pre><code class="language-js">if (!navigator.gpu) {
  console.error('WebGPU not supported')
}
</code></pre>
<p>En 2026, WebGPU est disponible dans Chrome, Edge, Firefox et Safari.</p>
<h2>2) Obtenir un périphérique</h2>
<pre><code class="language-js">const adapter = await navigator.gpu.requestAdapter()
const device = await adapter.requestDevice()
</code></pre>
<p>L'adaptateur représente le GPU physique. Le périphérique constitue votre interface avec celui-ci.</p>
<p>Les deux appels peuvent échouer. <code>requestAdapter()</code> renvoie <code>null</code> lorsque le système ne dispose d'aucun GPU utilisable, et <code>requestDevice()</code> peut rejeter la promesse. Prévoyez donc ces cas avant de poursuivre :</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>L'adaptateur est également consommé dès que vous créez un périphérique à partir de celui-ci. Demandez donc un nouvel adaptateur si vous avez besoin d'un autre périphérique.</p>
<h2>3) Configurer le canevas</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) Écrire des shaders en WGSL</h2>
<p>WebGPU utilise WGSL (WebGPU Shading Language), et non 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) Créer un module de shader</h2>
<pre><code class="language-js">const shaderModule = device.createShaderModule({
  code: wgslSource,
})
</code></pre>
<h2>6) Créer un pipeline de rendu</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) Effectuer le rendu d'une image</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) Les tampons dans 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) Groupes de liaison pour les variables uniformes</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 ou WebGL</h2>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>WebGL</th>
<th>WebGPU</th>
</tr>
</thead>
<tbody>
<tr>
<td>Style d'API</td>
<td>Similaire à OpenGL, avec état</td>
<td>Moderne, explicite</td>
</tr>
<tr>
<td>Shaders</td>
<td>GLSL</td>
<td>WGSL</td>
</tr>
<tr>
<td>Calcul</td>
<td>Limité (extensions)</td>
<td>Pris en charge nativement</td>
</tr>
<tr>
<td>Multithreading</td>
<td>Difficile</td>
<td>Conçu pour cela</td>
</tr>
<tr>
<td>Compatibilité des navigateurs</td>
<td>Universelle</td>
<td>Tous les principaux navigateurs (Chrome, Edge, Safari 26, Firefox)</td>
</tr>
</tbody>
</table>
<h2>Quand utiliser WebGPU</h2>
<ul>
<li>Nouveaux projets ciblant les navigateurs modernes</li>
<li>Charges de travail intensives en calcul (physique, IA, génération procédurale)</li>
<li>Lorsque vous avez besoin d'un contrôle précis sur les ressources GPU</li>
</ul>
<h2>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/webgl-fundamentals-for-games">Principes fondamentaux de WebGL</a></li>
<li><a href="/fr/tutorials/web-workers-game-logic">Web Workers pour la logique de jeu</a></li>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/guides/web-games-stack-2026">Pile technologique des jeux web en 2026</a> — cadre de décision pour choisir entre WebGL, WebGPU et Wasm</li>
<li><a href="/fr/tutorials/coop-coep-sharedarraybuffer">COOP/COEP et SharedArrayBuffer</a> — en-têtes requis pour le multithreading Wasm avec les calculs WebGPU</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://www.w3.org/TR/webgpu/">Spécification WebGPU</a> — spécification du W3C</li>
<li><a href="https://www.w3.org/TR/WGSL/">Spécification WGSL</a> — spécification du langage WebGPU Shading Language</li>
<li><a href="https://webgpufundamentals.org/">Principes fondamentaux de WebGPU</a> — série de tutoriels approfondis</li>
<li><a href="https://codelabs.developers.google.com/your-first-webgpu-app">Votre première application WebGPU</a> — tutoriel guidé Google Codelab</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Principes de base du multijoueur avec WebSocket]]></title>
            <link>https://app.cinevva.com/fr/tutorials/websocket-multiplayer-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/websocket-multiplayer-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Créez un mode multijoueur en temps réel avec WebSocket : gestion des connexions, protocoles de messages et méthodes de synchronisation pour les jeux web.]]></description>
            <content:encoded><![CDATA[<h1>Principes de base du multijoueur avec WebSocket</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">Créer un jeu multijoueur avec WebSocket : architecture serveur, salons de jeu et synchronisation de l’état</p>
<p>WebSocket permet une communication en temps réel pour les jeux multijoueurs. Ce tutoriel présente les principes fondamentaux côté client.</p>
<h2>1) Se connecter à un serveur</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) Concevoir le protocole de messages</h2>
<p>Définissez un protocole simple :</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) Classe de gestion des connexions</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) Envoyer les commandes du joueur</h2>
<p>Envoyez les commandes, pas les positions. Laissez le serveur faire autorité :</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) Recevoir l’état du jeu</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 pour des mouvements fluides</h2>
<p>Le serveur envoie des instantanés. Interpolez entre eux :</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) Prédiction côté client</h2>
<p>Pour des commandes réactives, effectuez une prédiction locale puis réconciliez-la avec le serveur :</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) Afficher la latence</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) Messages binaires pour plus d’efficacité</h2>
<p>Pour les mises à jour à haute fréquence, utilisez le format binaire :</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) Exemple de serveur simple (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>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/ship-web-game-fast">Publier un jeu web qui se charge rapidement</a></li>
<li><a href="/fr/tutorials/game-input-handling">Gestion des commandes de jeu</a></li>
<li><a href="/fr/tutorials/web-workers-game-logic">Web Workers pour la logique de jeu</a></li>
<li><a href="/fr/tutorials/coop-coep-sharedarraybuffer">COOP/COEP et SharedArrayBuffer</a> — les en-têtes requis pour SharedArrayBuffer dans les contextes multijoueurs</li>
<li><a href="/fr/guides/co-op-game-design">Conception de jeux coopératifs en 2026</a> — pourquoi les jeux coopératifs de type « friend slop » explosent et comment le multijoueur dans le navigateur s’intègre à cette tendance</li>
<li><a href="/fr/guides/game-jams-hackathons">Game jams et hackathons</a> — les jeux multijoueurs créés lors de jams attirent davantage l’attention</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API">MDN : API WebSocket</a> — référence de WebSocket dans le navigateur</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API">MDN : API WebRTC</a> — connexions pair à pair pour réduire la latence</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API">MDN : API WebTransport</a> — un protocole de transport HTTP/3 plus récent, devenu compatible avec l’ensemble des navigateurs de référence en mars 2026 lorsque Safari 26.4 a ajouté sa prise en charge (il était déjà disponible dans Chrome 97+, Edge et Firefox 114+). Contrairement à une connexion WebSocket unique basée sur TCP, WebTransport fonctionne sur QUIC et propose des datagrammes non fiables ainsi que plusieurs flux indépendants. Ainsi, la perte d’un paquet ne bloque pas tous les autres par attente en tête de file. Une approche courante consiste à envoyer les mises à jour fréquentes de position par datagrammes, tout en conservant le chat ou les tableaux des scores sur un flux fiable. WebSocket reste le choix le plus compatible et tout ce qui précède reste valable, mais WebTransport mérite désormais d’être envisagé pour les jeux sensibles à la latence.</li>
<li><a href="https://socket.io/docs/v4/">Documentation de Socket.IO</a> — bibliothèque populaire servant de surcouche à WebSocket</li>
<li><a href="https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking">Réseau multijoueur de Valve Source</a> — modèles de réseau faisant autorité utilisés dans les jeux en production</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Les bases de WebXR pour les jeux en VR]]></title>
            <link>https://app.cinevva.com/fr/tutorials/webxr-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/fr/tutorials/webxr-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Faites vos premiers pas avec WebXR : configuration des sessions, rendu, contrôleurs et interactions. Créez des expériences en VR qui fonctionnent dans le navigateur.]]></description>
            <content:encoded><![CDATA[<h1>Les bases de WebXR pour les jeux en VR</h1>
<p>WebXR apporte la VR et l’AR au navigateur. Les joueurs peuvent découvrir votre jeu dans un casque sans rien télécharger.</p>
<h2>1) Vérifier la prise en charge de WebXR</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 }
}

// Utilisation
const support = await checkXRSupport()
if (support.vr) {
  showVRButton()
}
</code></pre>
<h2>2) Démarrer une session VR</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)
    
    // Configurer le rendu
    const gl = canvas.getContext('webgl2', { xrCompatible: true })
    await xrSession.updateRenderState({
      baseLayer: new XRWebGLLayer(xrSession, gl),
    })
    
    // Obtenir l’espace de référence
    xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
    
    // Démarrer la boucle de rendu
    xrSession.requestAnimationFrame(onXRFrame)
  } catch (err) {
    console.error('Failed to start VR:', err)
  }
}

function onSessionEnd() {
  xrSession = null
  xrRefSpace = null
}
</code></pre>
<h2>3) La boucle de rendu XR</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)
  
  // Effectuer le rendu pour chaque œil
  for (const view of pose.views) {
    const viewport = glLayer.getViewport(view)
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
    
    // Obtenir les matrices de la caméra
    const viewMatrix = view.transform.inverse.matrix
    const projectionMatrix = view.projectionMatrix
    
    // Effectuer le rendu de la scène avec ces matrices
    renderScene(viewMatrix, projectionMatrix)
  }
}
</code></pre>
<h2>4) Sources d’entrée (contrôleurs)</h2>
<pre><code class="language-js">xrSession.addEventListener('inputsourceschange', (e) =&gt; {
  for (const source of e.added) {
    console.log('Controller added:', source.handedness) // 'left' ou '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
    
    // Gâchette (généralement à l’index 0)
    const triggerPressed = gamepad.buttons[0]?.pressed
    const triggerValue = gamepad.buttons[0]?.value || 0
    
    // Poignée (généralement à l’index 1)
    const gripPressed = gamepad.buttons[1]?.pressed
    
    // Stick analogique
    const thumbstickX = gamepad.axes[2] || 0
    const thumbstickY = gamepad.axes[3] || 0
    
    // Boutons A/B (index 4, 5)
    const buttonA = gamepad.buttons[4]?.pressed
    const buttonB = gamepad.buttons[5]?.pressed
  }
}
</code></pre>
<h3>Ne supposez pas que chaque appareil possède une manette</h3>
<p>Le code ci-dessus lit <code>source.gamepad</code> pour les gâchettes et les sticks analogiques. Cela fonctionne sur les casques avec contrôleurs comme le Meta Quest, mais ne produit aucun effet sur l’Apple Vision Pro (WebXR est activé par défaut dans Safari sous visionOS 2), le Samsung Galaxy XR et les autres appareils Android XR, où le regard et le pincement ou le suivi des mains constituent les principales méthodes d’entrée. Sur ces casques, <code>source.gamepad</code> vaut null et <code>session.inputSources</code> reste vide jusqu’à ce que l’utilisateur effectue réellement un pincement.</p>
<p>Pour la seule interaction garantie par tous les appareils, l’« action principale », écoutez les événements <code>select</code> au lieu d’interroger les boutons. WebXR émet <code>selectstart</code>, <code>select</code> et <code>selectend</code> aussi bien pour la gâchette d’un contrôleur, un pincement de la main ou une interaction par regard et pincement sur Vision Pro. Un seul gestionnaire suffit donc à les prendre tous en charge :</p>
<pre><code class="language-js">xrSession.addEventListener('selectstart', (e) =&gt; {
  // e.inputSource est le contrôleur, la main ou le pincement transitoire
  // e.frame permet de lire la pose de son targetRaySpace
})
xrSession.addEventListener('select', (e) =&gt; {
  // action principale terminée (gâchette pressée, pincement relâché sur la cible)
})
xrSession.addEventListener('selectend', (e) =&gt; {})
</code></pre>
<p>Conservez le code de la manette pour les déplacements au stick analogique et les boutons supplémentaires sur les casques avec contrôleurs, mais conditionnez-le avec <code>if (source.gamepad)</code> et faites passer votre interaction principale de « sélection/saisie/activation » par ces événements afin que le jeu fonctionne toujours sur les appareils pilotés par les mains et le regard.</p>
<h2>5) Positions des contrôleurs</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) Lancer de rayons pour le pointage</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) Déplacement par téléportation</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) {
        // Visée
        this.isAiming = true
        const ray = getControllerRay(frame, source)
        this.targetPosition = this.findTeleportTarget(ray)
      } else if (this.isAiming) {
        // Relâchement : téléportation
        if (this.targetPosition) {
          player.position.x = this.targetPosition.x
          player.position.z = this.targetPosition.z
        }
        this.isAiming = false
        this.targetPosition = null
      }
    }
  }
  
  findTeleportTarget(ray) {
    // Calculer l’intersection avec le plan du sol
    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) Déplacement fluide</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
    
    // Appliquer une zone morte
    if (Math.abs(moveX) &lt; 0.1) moveX = 0
    if (Math.abs(moveY) &lt; 0.1) moveY = 0
    
    // Obtenir la direction de la tête pour le déplacement
    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] }
    
    // Normaliser sur le plan XZ
    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) Recommandations pour le confort en VR</h2>
<pre><code class="language-js">// Ajouter une vignette pendant le déplacement pour réduire le mal des transports
function renderComfortVignette(movementSpeed) {
  const intensity = Math.min(movementSpeed / 5, 0.5)
  if (intensity &lt; 0.1) return
  
  // Afficher des bords sombres dont l’intensité augmente avec la vitesse
  renderVignette(intensity)
}

// Rotation par paliers
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 degrés
    player.rotation += angle
    snapTurnCooldown = 0.3 // Empêcher les rotations trop rapides
  }
}
</code></pre>
<h2>10) Configuration WebXR complète</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
    
    // Mettre à jour la logique du jeu
    this.teleport.update(frame, this.session.inputSources)
    
    // Effectuer le rendu
    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) {
    // Votre code de rendu ici
  }
  
  onEnd() {
    this.session = null
  }
}
</code></pre>
<h2>À lire aussi</h2>
<ul>
<li><a href="/fr/tutorials/webgl-fundamentals-for-games">Principes fondamentaux de WebGL</a></li>
<li><a href="/fr/tutorials/webgpu-getting-started">Premiers pas avec WebGPU</a></li>
<li><a href="/fr/tutorials/game-input-handling">Gestion des entrées de jeu</a></li>
<li><a href="/fr/guides/web-games-stack-2026">Stack technique des jeux web en 2026</a> — la place de WebXR dans l’écosystème technologique au sens large</li>
<li><a href="/fr/guides/threejs-usdc-tech-report">Three.js + USDC dans le navigateur</a> — charger des ressources 3D pour les scènes XR</li>
</ul>
<h2>Ressources externes</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API">MDN : API WebXR Device</a> — référence complète de l’API</li>
<li><a href="https://immersiveweb.dev/">Immersive Web</a> — exemples WebXR, outils et tableaux de compatibilité des navigateurs</li>
<li><a href="https://threejs.org/docs/#manual/en/introduction/How-to-create-VR-content">Guide WebXR de Three.js</a> — créer du contenu VR avec Three.js</li>
</ul>
]]></content:encoded>
        </item>
    </channel>
</rss>
