<?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 Tutoriales]]></title>
        <link>https://app.cinevva.com</link>
        <description><![CDATA[Tutoriales prácticos para quienes crean juegos 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[Herramientas de código con IA agéntica]]></title>
            <link>https://app.cinevva.com/es/tutorials/agentic-code-tools</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/agentic-code-tools</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Domina Claude Code, Cursor y GitHub Copilot: comprende los flujos de trabajo agénticos, las mejores prácticas y cuándo usar cada herramienta para maximizar la productividad.]]></description>
            <content:encoded><![CDATA[<h1>Herramientas de código con IA agéntica</h1>
<p>Los asistentes de programación con IA han evolucionado desde el simple autocompletado hasta agentes autónomos capaces de planificar, editar varios archivos, ejecutar pruebas y crear pull requests. Este tutorial aborda las tres herramientas principales: Claude Code, Cursor y GitHub Copilot.</p>
<h2>1) ¿Qué hace que una herramienta sea «agéntica»?</h2>
<p>El completado de código tradicional con IA sugiere las siguientes líneas. Las herramientas agénticas van más allá:</p>
<table>
<thead>
<tr>
<th>Capacidad</th>
<th>Autocompletado</th>
<th>Agéntica</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alcance</td>
<td>Línea/función actual</td>
<td>Base de código completa</td>
</tr>
<tr>
<td>Planificación</td>
<td>Ninguna</td>
<td>Razonamiento de varios pasos</td>
</tr>
<tr>
<td>Ejecución</td>
<td>Solo sugerencias</td>
<td>Puede editar archivos y ejecutar comandos</td>
</tr>
<tr>
<td>Contexto</td>
<td>Archivo actual</td>
<td>Comprensión de todo el repositorio</td>
</tr>
<tr>
<td>Flujo de trabajo</td>
<td>Reactivo</td>
<td>Finalización autónoma de tareas</td>
</tr>
</tbody>
</table>
<p>Las herramientas agénticas pueden comprender toda la estructura de tu proyecto, planificar cambios en varios archivos, ejecutar esos cambios, realizar pruebas e iterar hasta completar la tarea.</p>
<h2>2) Comparación rápida: Claude Code vs. Cursor vs. Copilot</h2>
<table>
<thead>
<tr>
<th>Función</th>
<th>Claude Code</th>
<th>Cursor</th>
<th>GitHub Copilot</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Interfaz</strong></td>
<td>CLI, web, plugins para IDE</td>
<td>IDE completo (fork de VS Code)</td>
<td>Extensiones para IDE</td>
</tr>
<tr>
<td><strong>Ventana de contexto</strong></td>
<td>Grande (Opus de contexto largo)</td>
<td>Indexación de todo el repositorio</td>
<td>Más pequeña, en mejora</td>
</tr>
<tr>
<td><strong>Edición de varios archivos</strong></td>
<td>Potente</td>
<td>Excelente</td>
<td>En mejora</td>
</tr>
<tr>
<td><strong>Modo de planificación</strong></td>
<td>Sí</td>
<td>Sí</td>
<td>Sí (más reciente)</td>
</tr>
<tr>
<td><strong>Precio</strong></td>
<td>Planes Pro/Max</td>
<td>Créditos + suscripción</td>
<td>Suscripción</td>
</tr>
<tr>
<td><strong>Ideal para</strong></td>
<td>Arquitectura, grandes refactorizaciones</td>
<td>Desarrollo interactivo</td>
<td>Programación en línea</td>
</tr>
</tbody>
</table>
<h2>3) Fundamentos de Claude Code</h2>
<p>Claude Code funciona con Opus 4.8 (su modelo predeterminado actual) y grandes ventanas de contexto. Instálalo y autentícate:</p>
<pre><code class="language-bash"># Instalar Claude Code (instalador nativo, recomendado)
curl -fsSL https://claude.ai/install.sh | bash

# O mediante npm (requiere Node 18+)
npm install -g @anthropic-ai/claude-code

# Autenticarse: ejecuta claude y sigue las indicaciones del navegador
claude

# Iniciar en tu proyecto
cd your-project
claude
</code></pre>
<h3>Comandos básicos</h3>
<pre><code class="language-bash"># Preguntar sobre tu base de código
claude &quot;¿Cómo está implementada aquí la autenticación?&quot;

# Generar código
claude &quot;Añade un middleware de limitación de solicitudes&quot;

# Refactorización de varios archivos
claude &quot;Cambia el nombre de UserService a AuthService en toda la base de código&quot;

# Crear un PR
claude &quot;Crea un PR que añada validación de entradas a todos los endpoints de la API&quot;
</code></pre>
<h3>Configuración del proyecto</h3>
<p>Incluye el contexto del proyecto en un archivo <code>CLAUDE.md</code> ubicado en la raíz del repositorio. Claude Code lo lee automáticamente al inicio de cada sesión:</p>
<pre><code class="language-markdown"># Notas del proyecto

- Lenguaje: TypeScript, formateado con Prettier
- Pruebas: Vitest, en tests/
- No modificar node_modules/ ni dist/
</code></pre>
<p>La configuración legible por máquinas (permisos de herramientas, variables de entorno y hooks) se guarda en <code>.claude/settings.json</code>. Por ejemplo, para exigir aprobación antes de ejecutar herramientas destructivas, configura allí los permisos.</p>
<h3>Memoria y contexto</h3>
<p>El contexto persistente del proyecto se guarda en archivos <a href="http://CLAUDE.md">CLAUDE.md</a>, que Claude Code carga al inicio de cada sesión, además de un almacén de memoria automática que Claude actualiza por sí mismo según tus correcciones y preferencias. Para proporcionarle contexto permanente, edita <a href="http://CLAUDE.md">CLAUDE.md</a> en lugar de enviar una instrucción puntual del tipo «Recuerda:»:</p>
<pre><code class="language-markdown"># CLAUDE.md
Este es un monorepositorio. El código compartido se encuentra en packages/.
</code></pre>
<p>Usa el comando <code>/clear</code> dentro de una sesión para restablecer el contexto de trabajo.</p>
<h2>4) Funciones agénticas del IDE Cursor</h2>
<p>Cursor está diseñado en torno a flujos de trabajo agénticos. Sus funciones principales son:</p>
<h3>Modo Agent</h3>
<p>Pulsa <code>Cmd+Shift+I</code> (Mac) o <code>Ctrl+Shift+I</code> (Windows/Linux) para abrir Composer en modo Agent:</p>
<pre><code>Flujo de trabajo del modo Agent:
1. Describe tu tarea
2. El agente crea un plan
3. Lo apruebas o modificas
4. El agente ejecuta los cambios
5. Revisas e iteras
</code></pre>
<h3>Modo Plan</h3>
<p>Para tareas complejas, usa explícitamente el modo Plan:</p>
<pre><code>@plan Añade autenticación de usuarios con:
- Tokens JWT
- Rotación de tokens de actualización
- Hashing de contraseñas con bcrypt
- Limitación de intentos de inicio de sesión
</code></pre>
<p>El agente:</p>
<ol>
<li>Investigará tu código existente</li>
<li>Creará un plan de implementación detallado</li>
<li>Esperará tu aprobación</li>
<li>Lo ejecutará paso a paso</li>
</ol>
<h3>Inyección de contexto</h3>
<p>Usa <code>@</code> para añadir contexto específico:</p>
<pre><code>@file src/api/auth.ts
@folder src/middleware
@docs https://jwt.io/introduction

Implementa un middleware de verificación de JWT siguiendo estos patrones
</code></pre>
<h3>Agentes en paralelo</h3>
<p>Ejecuta varios agentes simultáneamente en ramas diferentes:</p>
<pre><code># En Cursor:
1. Abre la paleta de comandos
2. &quot;Cursor: New Agent in Worktree&quot;
3. Cada agente trabaja de forma aislada
4. Compara los resultados en paralelo
</code></pre>
<p>Cursor 3 añadió una ventana de agentes (ábrela con Cmd+Shift+P y después «Agents Window») que ejecuta varios agentes en paralelo en checkouts locales, worktrees de git y entornos en la nube. El comando /worktree crea para cada agente su propio worktree aislado de git, mientras que /best-of-n ejecuta la misma tarea con varios modelos para que puedas comparar los resultados en paralelo.</p>
<h3>Reglas para mantener la coherencia</h3>
<p>Crea <code>.cursor/rules/project.mdc</code>:</p>
<pre><code class="language-markdown"># Reglas del proyecto

## Estilo del código
- Usar el modo estricto de TypeScript
- Preferir componentes funcionales
- Usar exportaciones con nombre, no predeterminadas

## Pruebas
- Cada funcionalidad necesita pruebas
- Usar bloques describe/it
- Simular servicios externos

## Seguridad
- Nunca guardar secretos en commits
- Validar todas las entradas del usuario
- Usar consultas parametrizadas
</code></pre>
<h2>5) Funciones agénticas de GitHub Copilot</h2>
<p>Copilot ha evolucionado más allá del autocompletado:</p>
<h3>Copilot Chat</h3>
<p>Abre el panel de chat y usa comandos con barra:</p>
<pre><code>/explain - Explica el código seleccionado
/fix - Corrige los problemas de la selección
/tests - Genera pruebas
/doc - Añade documentación
</code></pre>
<h3>Agente de programación (de issue a pull request)</h3>
<pre><code># En GitHub:
1. Abre un issue de GitHub
2. Asígnalo a &quot;Copilot&quot;
3. El agente de programación trabaja en segundo plano:
   analiza el issue, edita archivos y ejecuta pruebas
4. Abre un pull request para que lo revises
</code></pre>
<p>Esto es distinto del modo Agent de Copilot dentro del IDE, que trabaja de forma sincrónica contigo en VS Code o JetBrains.</p>
<p>Copilot ahora divide su trabajo autónomo en dos: el modo Agent se ejecuta de forma sincrónica dentro de tu IDE (VS Code y JetBrains), decide qué archivos editar, ejecuta comandos de terminal e itera sobre los errores; el agente de programación se ejecuta de forma asíncrona en la nube, recibe un issue asignado y entrega un pull request mientras tú trabajas en otra cosa.</p>
<h3>Sugerencias en línea con contexto</h3>
<p>Copilot lee tu base de código. Ayúdalo con comentarios:</p>
<pre><code class="language-typescript">// Middleware de autenticación que:
// - Extrae el JWT de la cabecera Authorization
// - Valida la firma del token
// - Adjunta el usuario a la solicitud
// - Devuelve 401 si no es válido
export function authMiddleware(req, res, next) {
  // Copilot genera la implementación
}
</code></pre>
<h2>6) Mejores prácticas para todas las herramientas</h2>
<h3>Escribe instrucciones claras</h3>
<pre><code>❌ Mal: &quot;Corrige el error&quot;

✅ Bien: &quot;Corrige la excepción de puntero nulo en UserService.getById 
cuando el usuario no exista. Devuelve null en lugar de lanzar una excepción.&quot;

❌ Mal: &quot;Añade pruebas&quot;

✅ Bien: &quot;Añade pruebas unitarias para CartService que cubran:
- Añadir artículos a un carrito vacío
- Eliminar artículos
- Calcular totales con descuentos
- Caso límite: cantidades negativas&quot;
</code></pre>
<h3>Usa el modo de planificación para tareas complejas</h3>
<pre><code>Para cualquier tarea que afecte a 3 archivos o más:

1. Pide siempre primero un plan
2. Revisa el plan
3. Apruébalo o modifícalo
4. Deja que el agente lo ejecute
5. Revisa los cambios antes de crear el commit
</code></pre>
<h3>Proporciona contexto arquitectónico</h3>
<pre><code>Antes de un trabajo importante, explica el contexto al agente:

&quot;Esta es una arquitectura de microservicios:
- api-gateway: Enruta las solicitudes
- auth-service: JWT, sesiones
- user-service: CRUD de usuarios
- notification-service: Correo electrónico, notificaciones push

Los servicios se comunican mediante pub/sub de Redis.
La base de datos es PostgreSQL con el ORM Prisma.&quot;
</code></pre>
<h3>Desarrollo guiado por pruebas con agentes</h3>
<pre><code>1. Escribe primero una prueba que falle:
   &quot;Escribe una prueba de registro de usuarios que compruebe la validación del correo electrónico&quot;

2. Deja que el agente vea la prueba fallida:
   &quot;Implementa el registro para que esta prueba pase&quot;

3. Itera hasta que todas pasen:
   &quot;Corrige las pruebas que todavía fallan&quot;
</code></pre>
<h2>7) Consideraciones de seguridad</h2>
<p>Las herramientas agénticas tienen un poder real. Establece límites:</p>
<h3>Controles de aprobación</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>Aislamiento</h3>
<pre><code class="language-bash"># Ejecutar agentes en entornos aislados
docker run -v $(pwd):/workspace -it claude-sandbox

# O usar worktrees de git para aislarlos
git worktree add ../feature-branch feature-branch
</code></pre>
<h3>Archivos confidenciales</h3>
<pre><code># .cursorignore / .claudeignore
.env
.env.*
secrets/
credentials/
*.pem
*.key
</code></pre>
<h3>Revisa todos los cambios</h3>
<pre><code class="language-bash"># Antes de aceptar los cambios del agente:
git diff --stat
git diff

# Usar staging interactivo
git add -p

# Nunca hacer una fusión automática sin revisión
</code></pre>
<h2>8) Patrones de flujo de trabajo que funcionan</h2>
<h3>Patrón 1: Explorar, planificar, ejecutar</h3>
<pre><code>Sesión 1: &quot;¿Cómo funciona el sistema de pagos?&quot;
          → El agente explora, tú aprendes

Sesión 2: &quot;¿Qué haría falta para añadir Stripe?&quot;
          → El agente crea un plan

Sesión 3: &quot;Implementa la integración con Stripe&quot;
          → El agente ejecuta el plan
</code></pre>
<h3>Patrón 2: Exploración en paralelo</h3>
<pre><code># En Cursor con agentes en paralelo:

Agente 1: &quot;Implementa almacenamiento en caché con Redis&quot;
Agente 2: &quot;Implementa almacenamiento en caché con Memcached&quot;
Agente 3: &quot;Implementa almacenamiento en caché con una LRU en memoria&quot;

→ Compara las implementaciones y elige el mejor enfoque
</code></pre>
<h3>Patrón 3: Migración incremental</h3>
<pre><code>Paso 1: &quot;Identifica todos los usos de la API obsoleta&quot;
Paso 2: &quot;Crea un adaptador para la nueva API&quot;
Paso 3: &quot;Migra el módulo A para que use el adaptador&quot;
Paso 4: &quot;Migra el módulo B...&quot;
...
Paso N: &quot;Elimina el adaptador y usa directamente la nueva API&quot;
</code></pre>
<h3>Patrón 4: Desarrollo de funcionalidades comenzando por las pruebas</h3>
<pre><code>1. &quot;Escribe pruebas de aceptación para la edición del perfil de usuario&quot;
2. &quot;Escribe pruebas unitarias para la validación del perfil&quot;
3. &quot;Implementa lo necesario para que las pruebas pasen&quot;
4. &quot;Añade pruebas de integración&quot;
5. &quot;Refactoriza para mejorar la claridad&quot;
</code></pre>
<h2>9) Cuándo usar cada herramienta</h2>
<table>
<thead>
<tr>
<th>Situación</th>
<th>Herramienta recomendada</th>
</tr>
</thead>
<tbody>
<tr>
<td>Programación en tiempo real, ayuda en línea</td>
<td>GitHub Copilot</td>
</tr>
<tr>
<td>Grandes refactorizaciones, arquitectura</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Sesión de desarrollo completa</td>
<td>Cursor</td>
</tr>
<tr>
<td>Preguntas rápidas sobre código</td>
<td>Cualquier interfaz de chat</td>
</tr>
<tr>
<td>Crear PR a partir de issues</td>
<td>Copilot Workspace</td>
</tr>
<tr>
<td>Cambios complejos en varios archivos</td>
<td>Cursor Agent</td>
</tr>
<tr>
<td>Diseño de API, documentación</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Aprender una nueva base de código</td>
<td>Cursor o Claude Code</td>
</tr>
</tbody>
</table>
<h2>10) Medición de la eficacia</h2>
<p>Haz seguimiento de estas métricas para evaluar las herramientas agénticas:</p>
<pre><code class="language-javascript">const metrics = {
  // Productividad
  suggestionsAccepted: 'porcentaje de sugerencias que conservaste',
  timeToFeature: 'horas desde el inicio hasta la fusión del PR',
  linesPerHour: 'velocidad de código (usar con cautela)',
  
  // Calidad
  bugsIntroduced: 'defectos atribuidos al código generado por IA',
  testCoverage: 'cobertura del código generado por IA',
  reviewChanges: 'cuánto modificas las sugerencias de la IA',
  
  // Coste
  apiCosts: 'gasto mensual en herramientas de IA',
  creditsUsed: 'créditos de Cursor consumidos',
  
  // Aprendizaje
  newPatternsLearned: 'técnicas que aprendiste de la IA',
  codebaseUnderstanding: 'cuánto te ayudó la IA a aprender',
}
</code></pre>
<h2>11) Errores comunes</h2>
<h3>Confiar demasiado en el agente</h3>
<pre><code>❌ Aceptar grandes cambios sin revisarlos
❌ Permitir que los agentes ejecuten comandos destructivos
❌ No comprender el código antes de publicarlo

✅ Revisar cada cambio
✅ Comprender lo que publicas
✅ Usar los agentes como colaboradores, no como sustitutos
</code></pre>
<h3>Contaminación del contexto</h3>
<pre><code>❌ Instrucciones enormes con contexto irrelevante
❌ No borrar el contexto entre tareas sin relación
❌ Incluir archivos generados en el contexto

✅ Mantener las instrucciones centradas
✅ Usar referencias específicas de @file
✅ Excluir los artefactos de compilación
</code></pre>
<h3>Ambigüedad en las instrucciones</h3>
<pre><code>❌ &quot;Mejóralo&quot;
❌ &quot;Corrige los problemas&quot;
❌ &quot;Añade algunas pruebas&quot;

✅ &quot;Reduce la complejidad temporal de O(n²) a O(n log n)&quot;
✅ &quot;Gestiona el caso null en la línea 45 devolviendo el resultado antes&quot;
✅ &quot;Añade pruebas para los casos límite: entrada vacía, longitud máxima, Unicode&quot;
</code></pre>
<h2>12) Lista de comprobación para empezar</h2>
<pre><code class="language-markdown">## Lista de comprobación de la configuración

### Claude Code
- [ ] Instalar la CLI: `npm install -g @anthropic/claude-code`
- [ ] Autenticarse: `claude login`
- [ ] Crear `.claude/config.json`
- [ ] Añadir `.claudeignore` para los archivos confidenciales

### Cursor
- [ ] Descargarlo desde cursor.com
- [ ] Importar la configuración de VS Code
- [ ] Configurar `.cursor/rules/`
- [ ] Aprender a usar `Cmd+Shift+I` para el modo Agent

### GitHub Copilot
- [ ] Instalar la extensión en tu IDE
- [ ] Activar Copilot Chat
- [ ] Probar Copilot Workspace con issues
- [ ] Configurar `.github/copilot-instructions.md`

### Todas las herramientas
- [ ] Configurar controles de aprobación para las operaciones destructivas
- [ ] Crear archivos de exclusión para los secretos
- [ ] Establecer un flujo de trabajo de revisión
- [ ] Registrar métricas desde el primer día
</code></pre>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/web-workers-game-logic">Web Workers para la lógica del juego</a></li>
<li><a href="/es/guides/game-jams-hackathons">Game jams y hackatones</a> — las herramientas de código con IA están cambiando lo que se puede lograr en una game jam de 48 horas</li>
<li><a href="/es/guides/web-game-engines-comparison">Comparación de motores de juegos web</a> — cómo elegir un motor que encaje bien con los flujos de trabajo asistidos por IA</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Analítica y telemetría para juegos web]]></title>
            <link>https://app.cinevva.com/es/tutorials/analytics-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/analytics-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Registra lo que importa en tu juego: comportamiento de los jugadores, rendimiento, errores y conversiones. Toma decisiones basadas en datos.]]></description>
            <content:encoded><![CDATA[<h1>Analítica y telemetría para juegos web</h1>
<p>La analítica te ayuda a entender qué hacen los jugadores, dónde encuentran dificultades y qué mantiene su interés. Este tutorial muestra qué registrar y cómo hacerlo.</p>
<h2>1) Qué registrar</h2>
<p><strong>Métricas de interacción:</strong></p>
<ul>
<li>Inicios y duración de las sesiones</li>
<li>Niveles iniciados y completados</li>
<li>Funciones utilizadas</li>
<li>Retención (visitas recurrentes)</li>
</ul>
<p><strong>Métricas de rendimiento:</strong></p>
<ul>
<li>Tiempo de carga</li>
<li>Tasa de fotogramas</li>
<li>Uso de memoria</li>
<li>Errores y cierres inesperados</li>
</ul>
<p><strong>Métricas de conversión:</strong></p>
<ul>
<li>Finalización del tutorial</li>
<li>Primera compra</li>
<li>Contenido compartido en redes sociales</li>
</ul>
<h2>2) Registro sencillo de eventos</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) Registro de sesiones</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) Eventos específicos del juego</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) Supervisión del rendimiento</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>Atribuir los tirones con Long Animation Frames</h3>
<p>El contador de FPS anterior te indica <em>cuándo</em> se pierden fotogramas, pero no <em>por qué</em>. La API Long Animation Frames (LoAF), incorporada en Chrome y Edge 123, cubre esa carencia. Detecta cualquier fotograma que tarde más de 50 ms y desglosa qué scripts provocaron el retraso, para que puedas atribuir los tirones a código específico en lugar de hacer conjeturas.</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>Por ahora, LoAF solo está disponible en Chromium (no en Firefox ni Safari), así que comprueba si la función está disponible antes de usarla y conserva el contador de FPS como referencia compatible con todos los navegadores.</p>
<h2>6) Registro de errores</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) Registro del tiempo de carga</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) Registro de embudos</h2>
<p>Registra el progreso de los jugadores a través de los flujos clave:</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) Compatibilidad con pruebas 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) Consideraciones sobre la privacidad</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;Utilizamos analítica para mejorar el juego. ¿Estás de acuerdo?&lt;/p&gt;
    &lt;button id=&quot;accept&quot;&gt;Aceptar&lt;/button&gt;
    &lt;button id=&quot;decline&quot;&gt;Rechazar&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>Alternativas de terceros</h2>
<p>Si no quieres crear tu propia solución:</p>
<ul>
<li><strong>Plausible</strong> — Sencillo y centrado en la privacidad</li>
<li><strong>Amplitude</strong> — Analítica de producto y embudos</li>
<li><strong>Mixpanel</strong> — Registro de eventos y recorridos de usuario</li>
<li><strong>Sentry</strong> — Especializado en el registro de errores</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>Relacionado</h2>
<ul>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/indexeddb-game-saves">Partidas guardadas con IndexedDB</a></li>
<li><a href="/es/creators">Para creadores</a></li>
<li><a href="/es/guides/itch-io-launch-guide">Cómo lanzar tu juego en itch.io</a> — analítica de <a href="http://itch.io">itch.io</a> y seguimiento de descargas</li>
<li><a href="/es/guides/steam-next-fest-strategy">Estrategia para Steam Next Fest</a> — medición de listas de deseos y tasas de conversión de la demo</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://plausible.io/">Plausible Analytics</a> — analítica ligera y respetuosa con la privacidad</li>
<li><a href="https://posthog.com/">PostHog</a> — analítica de producto de código abierto con registro de eventos</li>
<li><a href="https://sentry.io/">Sentry</a> — supervisión de errores e informes de cierres inesperados para aplicaciones web</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fundamentos del bucle de juego con Canvas 2D]]></title>
            <link>https://app.cinevva.com/es/tutorials/canvas-2d-game-loop</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/canvas-2d-game-loop</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Crea un bucle de juego sólido con Canvas 2D. Abarca la gestión del tiempo, el paso de tiempo fijo, la entrada, el renderizado y los errores comunes que suelen causar problemas.]]></description>
            <content:encoded><![CDATA[<h1>Fundamentos del bucle de juego con Canvas 2D</h1>
<p>El bucle de juego es el corazón de tu juego. Se ejecuta continuamente, actualizando el estado del juego y dibujando fotogramas tan rápido como lo permita el navegador. Si lo haces bien, todo lo demás será más fácil. Si lo haces mal, acabarás persiguiendo errores que solo aparecen en determinados equipos.</p>
<p><strong>Pruébalo (usa las teclas de dirección o WASD):</strong></p>
<GameLoopDemo />
<h2>La versión más sencilla</h2>
<p>Este es un bucle de juego reducido a lo esencial:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

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

function update() {
  // Lógica del juego aquí
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Dibujo aquí
}

gameLoop()
</code></pre>
<p>Esto funciona, pero tiene un problema. La función <code>update</code> no sabe cuánto tiempo ha pasado desde el último fotograma. En un monitor de 144 Hz, se ejecuta el doble de rápido que en uno de 60 Hz. La física de tu juego se comportará de forma diferente en distintos equipos.</p>
<h2>El tiempo delta soluciona los problemas de frecuencia de fotogramas</h2>
<p>Para que tu juego se ejecute a la misma velocidad en todas partes, necesitas registrar cuánto tiempo ha transcurrido entre fotogramas. Esto se denomina tiempo delta y suele abreviarse como <code>dt</code>.</p>
<pre><code class="language-js">let lastTime = 0

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

function update(dt) {
  player.x += player.speed * dt // ahora funciona con cualquier frecuencia de fotogramas
}

requestAnimationFrame(gameLoop)
</code></pre>
<p>La función de retorno de <code>requestAnimationFrame</code> recibe la marca de tiempo actual en milisegundos. La divido entre 1000 para obtener segundos porque así los cálculos resultan más sencillos. Una velocidad de 200 significa 200 píxeles por segundo, lo cual es intuitivo.</p>
<p>Hay algo que debes tener en cuenta: en el primer fotograma, <code>lastTime</code> es 0, por lo que <code>dt</code> será enorme. Algunas personas inicializan <code>lastTime</code> con <code>performance.now()</code> antes de iniciar el bucle, mientras que otras omiten la actualización del primer fotograma.</p>
<h2>Por qué usar requestAnimationFrame en lugar de setInterval</h2>
<p>Quizá te preguntes por qué usamos <code>requestAnimationFrame</code> en lugar de <code>setInterval</code>. Hay varias razones.</p>
<p>En primer lugar, <code>requestAnimationFrame</code> se sincroniza con la frecuencia de actualización de la pantalla, por lo que obtienes una animación fluida y sin desgarros. En segundo lugar, se pausa automáticamente cuando la pestaña está oculta, lo que ahorra batería y CPU. En tercer lugar, el navegador puede optimizarlo mejor.</p>
<p>Ese comportamiento de pausa suele ser lo que buscas, pero significa que el juego se congela cuando el usuario cambia de pestaña. Si estás creando un juego multijugador o algo que necesita seguir ejecutándose, tendrás que emplear otro enfoque.</p>
<h2>Paso de tiempo fijo para la física</h2>
<p>El tiempo delta variable funciona para movimientos sencillos, pero causa problemas en las simulaciones físicas. Si un fotograma tarda demasiado, tu personaje podría atravesar una pared porque la comprobación de colisiones no detectó el momento del impacto.</p>
<p>La solución es un paso de tiempo fijo. Acumulas tiempo y ejecutas las actualizaciones físicas a una frecuencia fija, independientemente de la velocidad a la que se rendericen los fotogramas.</p>
<pre><code class="language-js">const FIXED_DT = 1 / 60 // 60 actualizaciones físicas por segundo
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>Ese <code>Math.min(..., 0.25)</code> es importante. Si la pestaña estaba oculta y vuelve a mostrarse, <code>frameTime</code> podría ser enorme. Sin ese límite, el bucle <code>while</code> ejecutaría cientos de actualizaciones para intentar ponerse al día y congelaría el juego. Limitarlo a 0,25 segundos significa que nunca ejecutarás más de 15 actualizaciones físicas por fotograma.</p>
<h2>Cómo organizar el estado del juego</h2>
<p>A medida que tu juego crece, necesitas un lugar donde guardar todos los datos. Me gusta utilizar un único objeto que lo contenga todo:</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>Esto facilita guardar y cargar el estado del juego, reiniciarlo cuando muere el jugador y depurarlo escribiendo <code>game</code> en la consola. Algunas personas prefieren las clases, pero un objeto simple funciona perfectamente para juegos pequeños y medianos.</p>
<h2>Uniendo todas las piezas</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
  
  // Mantener dentro de los límites
  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)
}

// Gestión de la entrada
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>Este es un bucle de juego completo y ejecutable. El jugador se mueve con las teclas de dirección o WASD, permanece dentro de los límites y todo se ejecuta a una velocidad constante independientemente de la frecuencia de fotogramas. Puedes copiarlo como punto de partida para cualquier juego con Canvas 2D.</p>
<p>Observa cómo la gestión de la entrada está separada del bucle de juego. Los escuchadores de eventos se limitan a establecer indicadores y la función de actualización lee esos indicadores. Este desacoplamiento es importante porque los eventos del teclado se activan en momentos impredecibles, mientras que necesitas que la lógica del juego se ejecute a una frecuencia predecible.</p>
<h2>Cargar y dibujar 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>Las imágenes se cargan de forma asíncrona, así que debes esperar a que estén listas antes de iniciar el juego. Un patrón habitual consiste en cargar todos los sprites en una función <code>loadAssets</code> que devuelva una promesa y, después, iniciar el bucle de juego únicamente cuando esta se resuelva.</p>
<p>La función <code>drawSprite</code> comprueba si el sprite existe antes de dibujarlo. Esto evita errores si intentas dibujar algo que aún no se ha cargado.</p>
<h2>Sprites animados</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>Esta clase supone que tu hoja de sprites tiene los fotogramas dispuestos horizontalmente en una sola fila. Todos los fotogramas tienen el mismo tamaño. El método <code>update</code> avanza por los fotogramas según el tiempo transcurrido y <code>draw</code> utiliza la versión de nueve argumentos de <code>drawImage</code> para recortar únicamente el fotograma actual.</p>
<p>La usarías así:</p>
<pre><code class="language-js">const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// En update:
playerWalk.update(dt)

// En render:
playerWalk.draw(ctx, player.x, player.y)
</code></pre>
<h2>Detección de colisiones</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
}

// En update:
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}
</code></pre>
<p>La colisión entre rectángulos —también llamada AABB, por «caja delimitadora alineada con los ejes»— es la opción más sencilla y rápida. Úsala para la mayoría de los elementos. La colisión entre círculos es mejor para objetos redondos o cuando quieres que las colisiones resulten más permisivas.</p>
<p>Con un número reducido de entidades, no hay problema en comprobar cada par. Si tienes cientos de entidades, necesitarás particionamiento espacial —árboles cuádruples o una cuadrícula—, pero ese es un tema para otro tutorial.</p>
<h2>Una cámara sencilla</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)
  
  // Dibujar el mundo
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // Dibujar la interfaz de usuario (no afectada por la cámara)
  drawUI()
}
</code></pre>
<p>El truco está en <code>ctx.save()</code> y <code>ctx.restore()</code>. Guardas el estado actual de la transformación, aplicas el desplazamiento de la cámara con <code>translate</code>, dibujas todo en el espacio del mundo y después restauras el estado para que los elementos de la interfaz no se vean afectados por la cámara.</p>
<p>Para conseguir un movimiento de cámara más fluido, puedes interpolar linealmente hacia el objetivo en lugar de saltar directamente hasta él. Algo como <code>this.x += (targetX - this.x) * 0.1</code> produce ese agradable efecto de seguimiento con retraso.</p>
<h2>Pausar el juego</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('EN PAUSA', canvas.width / 2, canvas.height / 2)
}
</code></pre>
<p>La idea clave es que dejas de acumular tiempo durante la pausa, pero sigues renderizando. De este modo, la superposición de pausa aparece de inmediato y el juego sigue respondiendo a la entrada para reanudarlo.</p>
<p>Podrías ampliar este patrón para gestionar varios estados del juego, como menús, escenas cinemáticas y pantallas de fin de partida. Basta con comprobar el estado actual y dirigir la ejecución a distintas funciones de actualización y renderizado.</p>
<h2>Errores comunes</h2>
<p>Estas son algunas cosas que suelen causar problemas:</p>
<p><strong>Olvidarse de limpiar el lienzo.</strong> Si no llamas a <code>clearRect</code> al principio del renderizado, dibujarás encima del fotograma anterior y aparecerán estelas.</p>
<p><strong>No limitar el tiempo delta.</strong> Si alguien cambia de pestaña y vuelve, un tiempo delta enorme puede romper la física o hacer que el juego intente «ponerse al día» durante varios segundos.</p>
<p><strong>Dibujar antes de que se carguen las imágenes.</strong> Canvas ignora silenciosamente las llamadas a <code>drawImage</code> con imágenes que todavía no se han cargado. Tus sprites simplemente no aparecerán y no recibirás ningún mensaje de error.</p>
<p><strong>Usar <code>setInterval</code> para el bucle.</strong> No se sincroniza con la pantalla ni se pausa cuando la pestaña está oculta. Usa <code>requestAnimationFrame</code>.</p>
<p><strong>Mezclar coordenadas del mundo y de la pantalla.</strong> Cuando añadas una cámara, lleva un control de si una posición está en el espacio del mundo o en el de la pantalla. Los clics del ratón llegan en coordenadas de pantalla y deben convertirse.</p>
<h2>Próximos pasos</h2>
<p>Este tutorial abarca los fundamentos. Cuando te sientas cómodo con estos patrones, quizá quieras explorar:</p>
<p><a href="/es/tutorials/responsive-game-canvas">Canvas adaptable para juegos</a> para gestionar distintos tamaños de pantalla y pantallas con alta densidad de píxeles.</p>
<p><a href="/es/tutorials/game-input-handling">Gestión de la entrada en juegos</a> para controles táctiles, mandos y sistemas de entrada más sofisticados.</p>
<p><a href="/es/tutorials/pixel-art-rendering">Renderizado de pixel art</a> para obtener gráficos de píxeles nítidos y sin desenfoque.</p>
<p><a href="/es/tutorials/game-physics-libraries">Bibliotecas de física para juegos</a> para añadir física de cuerpos rígidos, colisiones y articulaciones a tu bucle de juego.</p>
<p>Si estás decidiendo entre Canvas 2D, WebGL y WebGPU para tu proyecto, la guía <a href="/es/guides/web-games-stack-2026">Tecnologías para juegos web en 2026</a> explica sus ventajas y desventajas. La <a href="/es/guides/web-game-engines-comparison">Comparativa de motores de juegos web</a> analiza los frameworks que gestionan el bucle de juego por ti.</p>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame">MDN: requestAnimationFrame</a> — referencia de la API del navegador</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API">MDN: API de Canvas</a> — documentación completa de Canvas 2D</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent">MDN: KeyboardEvent</a> — códigos de teclas y propiedades de eventos</li>
<li><a href="https://gafferongames.com/post/fix_your_timestep/">¡Corrige tu paso de tiempo!</a> — el clásico artículo de Glenn Fiedler sobre bucles de juego con paso de tiempo fijo</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Habilita los hilos de Wasm (SharedArrayBuffer) con COOP/COEP]]></title>
            <link>https://app.cinevva.com/es/tutorials/coop-coep-sharedarraybuffer</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/coop-coep-sharedarraybuffer</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Guía práctica lista para copiar y pegar sobre el aislamiento entre orígenes: cabeceras necesarias, errores comunes y ejemplos de configuración del servidor para SharedArrayBuffer y los hilos de WebAssembly. Incluye un comprobador gratuito para verificar si cualquier URL está aislada entre orígenes.]]></description>
            <content:encoded><![CDATA[<h1>Habilita los hilos de Wasm (SharedArrayBuffer) con COOP/COEP</h1>
<p>Si tu juego web (o la exportación de tu motor) utiliza <strong>hilos de WebAssembly</strong>, normalmente depende de <code>SharedArrayBuffer</code>. Los navegadores modernos requieren <strong>aislamiento entre orígenes</strong> para ello.</p>
<p><img src="/img/cross-origin-isolation.svg" alt="Diagrama: cabeceras de aislamiento entre orígenes"></p>
<h2>1) Comprobación rápida: ¿tu sitio está aislado?</h2>
<p>Abre DevTools y ejecuta:</p>
<pre><code class="language-js">self.crossOriginIsolated
</code></pre>
<p>Si el resultado es <code>true</code>, estás en el estado de seguridad adecuado para utilizar <code>SharedArrayBuffer</code>.</p>
<p>Referencia oficial:</p>
<ul>
<li><code>https://developer.mozilla.org/docs/Web/API/Window/crossOriginIsolated</code></li>
</ul>
<h2>2) Las dos cabeceras que necesitas</h2>
<p>Envía estas cabeceras de respuesta en tu HTML (y, por lo general, en toda tu aplicación):</p>
<pre><code class="language-http">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<p>Referencias oficiales:</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 razón principal por la que «sigue sin funcionar»: los subrecursos</h2>
<p>Con <code>COEP: require-corp</code>, tu página ya no puede incrustar libremente recursos de otros orígenes.</p>
<p>Si cargas un script, una imagen, un worker, un vídeo u otro recurso desde otro origen, ese recurso debe autorizarlo mediante:</p>
<ul>
<li>CORS (<code>Access-Control-Allow-Origin</code>) <strong>o</strong></li>
<li>CORP (<code>Cross-Origin-Resource-Policy</code>)</li>
</ul>
<p>Referencia oficial:</p>
<ul>
<li><code>https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cross-Origin_Resource_Policy</code></li>
</ul>
<h2>4) Configuraciones listas para copiar y pegar</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) Lista de comprobación para depurar problemas</h2>
<ul>
<li>Confirma que las cabeceras estén presentes en la respuesta del <strong>documento</strong> en el panel Red.</li>
<li>Comprueba <code>self.crossOriginIsolated</code>.</li>
<li>Busca solicitudes bloqueadas (un mensaje habitual de DevTools incluye “blocked … by COEP”).</li>
<li>Elimina o aloja en tu propio servidor los scripts de terceros que no sean compatibles con CORS/CORP.</li>
</ul>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/guides/web-games-stack-2026">Tecnologías para juegos web en 2026</a></li>
<li><a href="/es/tutorials/web-workers-game-logic">Web Workers para la lógica del juego</a> — uso de Workers que se benefician de SharedArrayBuffer</li>
<li><a href="/es/tutorials/websocket-multiplayer-basics">Conceptos básicos del multijugador con WebSocket</a> — juegos multijugador que necesitan aislamiento entre orígenes</li>
<li><a href="/es/tutorials/iframe-embedding-games">Incrustar juegos mediante iframe</a> — cómo afectan las cabeceras COOP/COEP a las incrustaciones mediante iframe</li>
</ul>
<h2>Recursos externos</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> — referencia de la cabecera 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> — referencia de la cabecera COEP</li>
<li><a href="https://web.dev/articles/cross-origin-isolation-guide">web.dev: Cómo aislar tu sitio web entre orígenes</a> — guía práctica de despliegue</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Gestión de entradas del juego (teclado, ratón, pantalla táctil, mando)]]></title>
            <link>https://app.cinevva.com/es/tutorials/game-input-handling</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/game-input-handling</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Gestión unificada de entradas para juegos web: compatibilidad con teclado, ratón, pantalla táctil y mando mediante patrones de código prácticos.]]></description>
            <content:encoded><![CDATA[<h1>Gestión de entradas del juego</h1>
<p>Una buena gestión de las entradas puede determinar el éxito o el fracaso de un juego. Los controles con retraso, las entradas que no se detectan o las asignaciones de botones confusas frustran a los jugadores antes incluso de que vean el contenido del juego. Este tutorial muestra cómo gestionar el teclado, el ratón, la pantalla táctil y el mando de forma unificada.</p>
<p><strong>Pruébalo primero:</strong></p>
<InputDemo />
<h2>El patrón de estado de entrada</h2>
<p>El enfoque ingenuo consiste en reaccionar directamente a los eventos: cuando el usuario pulsa una tecla, se realiza una acción. Esto causa problemas. Los eventos se disparan en momentos impredecibles, mientras que el bucle del juego se ejecuta a una frecuencia fija. Si mueves al jugador desde el controlador de <code>keydown</code>, la velocidad de movimiento dependerá de la frecuencia de repetición de teclas, que varía según el sistema operativo.</p>
<p>En su lugar, mantén un objeto de estado de entrada que pueda leer el bucle del juego:</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>Los controladores de eventos establecen estos indicadores. El bucle del juego los lee. Este desacoplamiento lo simplifica todo: tu lógica de actualización no necesita saber si la entrada procede del teclado, de la pantalla táctil o del mando.</p>
<h2>Teclado</h2>
<p>Usa <code>e.code</code> en lugar de <code>e.key</code>, ya que hace referencia a la posición física de la tecla. WASD funciona correctamente en distribuciones de teclado que no sean 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><code>e.preventDefault()</code> evita que las teclas de flecha desplacen la página. Llámalo únicamente para las teclas que estés gestionando.</p>
<h2>Ratón</h2>
<p>Las coordenadas del ratón se expresan en el espacio de la pantalla. Debes convertirlas al espacio del lienzo:</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 el lienzo está escalado (consulta el tutorial sobre lienzos adaptables), tendrás que dividir entre el factor de escala para obtener las coordenadas del juego.</p>
<h2>Pantalla táctil</h2>
<p>La entrada táctil funciona de forma similar al ratón, pero necesitas <code>e.preventDefault()</code> para impedir que el navegador desplace o amplíe la página:</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>Se requiere <code>{ passive: false }</code> porque, por motivos de rendimiento, los navegadores modernos usan de forma predeterminada escuchadores pasivos para los eventos táctiles. Sin esta opción, <code>preventDefault</code> no funcionará.</p>
<h2>Cruceta virtual para dispositivos móviles</h2>
<p>Para los juegos que necesiten controles direccionales en dispositivos móviles, añade botones en pantalla:</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>Aplica estilos a estos botones para que sean lo bastante grandes como para pulsarlos con los pulgares (al menos 44 × 44 píxeles) y colócalos en las esquinas inferiores.</p>
<h2>Mando</h2>
<p>A diferencia del teclado y el ratón, el estado del mando no se controla mediante eventos. Debes consultarlo en cada fotograma:</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>Llama a <code>pollGamepads()</code> al principio de la función de actualización. El umbral de 0,5 en los sticks analógicos crea una zona muerta para que los pequeños movimientos del stick no se registren como entradas.</p>
<p>Puedes detectar cuándo se conectan y desconectan los mandos:</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>Uso de las entradas en el bucle del juego</h2>
<p>Ahora la función de actualización solo tiene que leer el estado de entrada:</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>Esto funciona con independencia de que la entrada proceda del teclado, de la pantalla táctil o del mando. La lógica del juego no necesita saberlo.</p>
<h2>Recién pulsado frente a mantenido</h2>
<p>A veces necesitas detectar si un botón acaba de pulsarse en este fotograma, no si se mantiene pulsado en ese momento. Saltar es un buen ejemplo: quieres un salto por cada pulsación, no saltos continuos mientras se mantenga pulsado el botón.</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>Llama a <code>endFrame()</code> al final del bucle de actualización. Después, usa <code>wasJustPressed('action')</code> en lugar de <code>input.action</code> para las acciones que solo deben ejecutarse una vez por pulsación.</p>
<h2>Pérdida de foco</h2>
<p>Cuando la ventana del navegador pierde el foco, dejan de dispararse los eventos del teclado. Si el jugador estaba manteniendo pulsada una tecla, esta se queda atascada:</p>
<pre><code class="language-js">window.addEventListener('blur', () =&gt; {
  input.left = input.right = input.up = input.down = input.action = false
})
</code></pre>
<p>Sin esto, el jugador seguirá moviéndose cuando el usuario cambie de ventana con Alt+Tab.</p>
<h2>Prácticas recomendadas</h2>
<p>Admite tanto WASD como las teclas de flecha, ya que cada jugador tiene sus preferencias. Permite reasignar los controles y guarda la configuración en localStorage. Proporciona controles táctiles en dispositivos móviles. Realiza pruebas con mandos reales, ya que los mandos de Xbox, PlayStation y los genéricos se comportan de manera ligeramente diferente. Y gestiona siempre la pérdida de foco correctamente.</p>
<h2>Más recursos</h2>
<p><a href="/es/tutorials/gamepad-api">Análisis detallado de la API de mandos</a> explica funciones avanzadas de los mandos, como la vibración y la compatibilidad con varios jugadores.</p>
<p><a href="/es/tutorials/mobile-friendly-web-games">Juegos web adaptados a dispositivos móviles</a> explica la entrada táctil con más detalle.</p>
<p><a href="/es/tutorials/pointer-lock-fps">Bloqueo del puntero para juegos FPS</a> muestra cómo capturar el ratón para los controles en primera persona.</p>
<p><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a> muestra el patrón de estado de entrada integrado en un bucle de juego completo.</p>
<p><a href="/es/tutorials/websocket-multiplayer-basics">Conceptos básicos del multijugador con WebSocket</a> explica cómo sincronizar las entradas entre jugadores conectados en red.</p>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code">MDN: KeyboardEvent.code</a> — valores de las teclas físicas para asignar las entradas</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events">MDN: Eventos de puntero</a> — eventos unificados de ratón y entrada táctil</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN: API de mandos</a> — compatibilidad con mandos en el navegador</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN: Eventos táctiles</a> — gestión multitáctil en dispositivos móviles</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Física para juegos web]]></title>
            <link>https://app.cinevva.com/es/tutorials/game-physics-libraries</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/game-physics-libraries</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Compara motores de física para juegos web: Rapier, Cannon-es, Ammo.js, Matter.js y Planck.js. Pruebas de rendimiento, ejemplos de código y cuándo usar cada uno.]]></description>
            <content:encoded><![CDATA[<h1>Física para juegos web</h1>
<p>La simulación física da vida a los juegos: objetos que caen, pelotas que rebotan, muñecos de trapo, vehículos y entornos destructibles. Esta guía abarca las principales bibliotecas de física disponibles para juegos web, con ejemplos de código reales y ventajas y desventajas honestas.</p>
<h2>Resumen</h2>
<table>
<thead>
<tr>
<th>Motor</th>
<th style="text-align:center">Dimensión</th>
<th style="text-align:center">Rendimiento</th>
<th style="text-align:center">Tamaño</th>
<th style="text-align:center">Dificultad</th>
<th style="text-align:center">Cuerpos blandos</th>
<th style="text-align:center">Vehículos</th>
<th style="text-align:center">CCD</th>
<th style="text-align:center">Determinismo</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Rapier</strong></td>
<td style="text-align:center">2D/3D</td>
<td style="text-align:center">⭐⭐⭐⭐⭐</td>
<td style="text-align:center">1,4 MB</td>
<td style="text-align:center">Media</td>
<td style="text-align:center">—</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
</tr>
<tr>
<td><strong>Cannon-es</strong></td>
<td style="text-align:center">3D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">150 KB</td>
<td style="text-align:center">Fácil</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
</tr>
<tr>
<td><strong>Ammo.js</strong></td>
<td style="text-align:center">3D</td>
<td style="text-align:center">⭐⭐⭐⭐⭐</td>
<td style="text-align:center">1-2 MB</td>
<td style="text-align:center">Difícil</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">Difícil</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">✅</td>
</tr>
<tr>
<td><strong>Oimo.js</strong></td>
<td style="text-align:center">3D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">100 KB</td>
<td style="text-align:center">Fácil</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
</tr>
<tr>
<td><strong>Matter.js</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">80 KB</td>
<td style="text-align:center">Fácil</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">⚠️</td>
</tr>
<tr>
<td><strong>Planck.js</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐⭐</td>
<td style="text-align:center">120 KB</td>
<td style="text-align:center">Media</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">✅</td>
<td style="text-align:center">⚠️</td>
</tr>
<tr>
<td><strong>p2-es</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐</td>
<td style="text-align:center">100 KB</td>
<td style="text-align:center">Media</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
<td style="text-align:center">—</td>
</tr>
<tr>
<td><strong>Box2D WASM</strong></td>
<td style="text-align:center">2D</td>
<td style="text-align:center">⭐⭐⭐⭐⭐</td>
<td style="text-align:center">300 KB</td>
<td style="text-align:center">Difícil</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>Leyenda:</strong></p>
<ul>
<li>CCD = Detección continua de colisiones (evita que los objetos rápidos atraviesen las paredes)</li>
<li>⚠️ Determinismo = Es posible con un intervalo de tiempo fijo, pero no está garantizado entre distintas plataformas</li>
</ul>
<h3>Recomendaciones rápidas</h3>
<table>
<thead>
<tr>
<th>Tu situación</th>
<th>Mejor opción</th>
</tr>
</thead>
<tbody>
<tr>
<td>Juego 3D para producción</td>
<td><strong>Rapier</strong> — el mejor rendimiento, API moderna y desarrollo activo</td>
</tr>
<tr>
<td>Aprendizaje / prototipo</td>
<td><strong>Cannon-es</strong> (3D) o <strong>Matter.js</strong> (2D) — API sencillas y depuración fácil</td>
</tr>
<tr>
<td>Física de vehículos</td>
<td><strong>Rapier</strong> o <strong>Ammo.js</strong> — ambos tienen controladores de vehículos mediante ray casting</td>
</tr>
<tr>
<td>Cuerpos blandos, telas y cuerdas</td>
<td><strong>Ammo.js</strong> o <strong>Jolt (JoltPhysics.js)</strong> — ambos admiten telas y cuerpos blandos; el port WASM de Jolt tiene un mantenimiento más activo</td>
</tr>
<tr>
<td>Juego de plataformas 2D preciso</td>
<td><strong>Planck.js</strong> — algoritmos de Box2D y determinismo con un intervalo de tiempo fijo</td>
</tr>
<tr>
<td>Máximo rendimiento en 2D</td>
<td><strong>Box2D WASM</strong> — velocidad nativa en el navegador</td>
</tr>
<tr>
<td>Paquete más pequeño</td>
<td><strong>Oimo.js</strong> (3D) o <strong>Matter.js</strong> (2D)</td>
</tr>
</tbody>
</table>
<hr>
<h2>Comparación detallada</h2>
<h3>Motores de física 3D</h3>
<table>
<thead>
<tr>
<th>Motor</th>
<th>Lenguaje</th>
<th>Tamaño</th>
<th>Rendimiento</th>
<th>Ideal para</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Rapier</strong></td>
<td>Rust/WASM</td>
<td>~1,4 MB</td>
<td>Excelente</td>
<td>Juegos para producción y simulaciones complejas</td>
</tr>
<tr>
<td><strong>Cannon-es</strong></td>
<td>JavaScript</td>
<td>~150 KB</td>
<td>Bueno</td>
<td>Prototipos y juegos sencillos</td>
</tr>
<tr>
<td><strong>Ammo.js</strong></td>
<td>C++/WASM</td>
<td>~1-2 MB</td>
<td>Excelente</td>
<td>Funciones de nivel AAA, cuerpos blandos y vehículos</td>
</tr>
<tr>
<td><strong>Jolt</strong></td>
<td>C++/WASM</td>
<td>WASM</td>
<td>Excelente</td>
<td>Funciones de nivel AAA con mantenimiento activo</td>
</tr>
<tr>
<td><strong>Oimo.js</strong></td>
<td>JavaScript</td>
<td>~100 KB</td>
<td>Bueno</td>
<td>Juegos sencillos y prototipos rápidos</td>
</tr>
</tbody>
</table>
<h3>Motores de física 2D</h3>
<table>
<thead>
<tr>
<th>Motor</th>
<th>Lenguaje</th>
<th>Tamaño</th>
<th>Rendimiento</th>
<th>Ideal para</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Matter.js</strong></td>
<td>JavaScript</td>
<td>~80 KB</td>
<td>Bueno</td>
<td>Juegos visuales y prototipos</td>
</tr>
<tr>
<td><strong>Planck.js</strong></td>
<td>JavaScript</td>
<td>~120 KB</td>
<td>Bueno</td>
<td>Juegos de plataformas y física precisa</td>
</tr>
<tr>
<td><strong>p2-es</strong></td>
<td>JavaScript</td>
<td>~100 KB</td>
<td>Bueno</td>
<td>Restricciones y mecanismos</td>
</tr>
<tr>
<td><strong>Box2D WASM</strong></td>
<td>C++/WASM</td>
<td>~300 KB</td>
<td>Excelente</td>
<td>Grandes cantidades de cuerpos</td>
</tr>
</tbody>
</table>
<hr>
<h2>Motores de física 3D</h2>
<h3>Rapier — La opción moderna</h3>
<p>Rapier es un motor de física escrito en Rust con enlaces para JavaScript/WASM. Es la opción de mayor rendimiento para juegos web en 2025-2026, con mejoras de velocidad de entre 2 y 5 veces respecto a su versión de 2024.</p>
<p><strong>Instalación:</strong></p>
<pre><code class="language-bash">npm install @dimforge/rapier3d
# O con SIMD (más rápido, requiere navegadores modernos):
npm install @dimforge/rapier3d-simd
</code></pre>
<p><strong>Configuración básica:</strong></p>
<pre><code class="language-js">import RAPIER from '@dimforge/rapier3d'

// Inicialización (WASM requiere que sea asíncrona)
await RAPIER.init()

// Crear un mundo con gravedad
const gravity = { x: 0, y: -9.81, z: 0 }
const world = new RAPIER.World(gravity)

// Crear el suelo (cuerpo estático)
const groundDesc = RAPIER.RigidBodyDesc.fixed()
const groundBody = world.createRigidBody(groundDesc)
const groundCollider = RAPIER.ColliderDesc.cuboid(50, 0.1, 50)
world.createCollider(groundCollider, groundBody)

// Crear una caja que cae (cuerpo dinámico)
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>Integración con el bucle del juego:</strong></p>
<pre><code class="language-js">const FIXED_TIMESTEP = 1 / 60

function physicsStep() {
  world.step()
}

function gameLoop() {
  physicsStep()
  
  // Sincronizar los objetos renderizados con la física
  const position = boxBody.translation()
  const rotation = boxBody.rotation()
  
  // Actualizar tu malla de Three.js/Babylon
  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>Detección de colisiones:</strong></p>
<pre><code class="language-js">// Detección de colisiones basada en eventos
world.contactPairsWith(boxCollider, (otherCollider) =&gt; {
  console.log('La caja está tocando:', 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('Impacto en:', hitPoint)
}
</code></pre>
<p><strong>Articulaciones:</strong></p>
<pre><code class="language-js">// Crear una articulación de bisagra (puerta)
const jointData = RAPIER.JointData.revolute(
  { x: 0, y: 0, z: 0 },  // Anclaje en body1
  { x: -1, y: 0, z: 0 }, // Anclaje en body2
  { x: 0, y: 1, z: 0 }   // Eje de rotación
)
world.createImpulseJoint(jointData, body1, body2, true)
</code></pre>
<p><strong>Controlador de vehículos:</strong></p>
<pre><code class="language-js">// Crear el cuerpo rígido del chasis
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)

// Crear el controlador del vehículo
const vehicle = world.createVehicleController(chassis)

// Añadir ruedas (delantera izquierda, delantera derecha, trasera izquierda, trasera derecha)
const suspensionRestLength = 0.3
const wheelRadius = 0.4

// Ruedas delanteras (dirección)
vehicle.addWheel(
  { x: -0.8, y: 0, z: 1.2 },   // Punto de conexión
  { x: 0, y: -1, z: 0 },       // Dirección de la suspensión
  { x: -1, y: 0, z: 0 },       // Dirección del eje
  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
)

// Ruedas traseras (tracción)
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
)

// Configurar la suspensión de todas las ruedas
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)
}

// En el bucle del juego
function updateVehicle(steering, engineForce, brakeForce) {
  // Dirección (solo ruedas delanteras)
  vehicle.setWheelSteering(0, steering)
  vehicle.setWheelSteering(1, steering)
  
  // Motor (ruedas traseras)
  vehicle.setWheelEngineForce(2, engineForce)
  vehicle.setWheelEngineForce(3, engineForce)
  
  // Frenos (todas las ruedas)
  for (let i = 0; i &lt; 4; i++) {
    vehicle.setWheelBrake(i, brakeForce)
  }
  
  // Actualizar la física del vehículo
  vehicle.updateVehicle(world.timestep)
}
</code></pre>
<p><strong>Ventajas:</strong></p>
<ul>
<li>El mejor rendimiento (WASM + SIMD)</li>
<li>Excelente precisión en la detección de colisiones</li>
<li>Determinismo multiplataforma (mismas entradas = mismas salidas)</li>
<li>Desarrollo activo y API moderna</li>
<li>Detección continua de colisiones (sin atravesar objetos)</li>
<li>Controladores de personajes y vehículos integrados</li>
</ul>
<p><strong>Desventajas:</strong></p>
<ul>
<li>Paquete más grande (~1,4 MB)</li>
<li>Requiere inicialización asíncrona</li>
<li>API más compleja que las alternativas de JS puro</li>
<li>La depuración de WASM puede ser complicada</li>
</ul>
<hr>
<h3>Cannon-es — Sencillo y eficaz</h3>
<p>Cannon-es es el fork con mantenimiento activo de Cannon.js. Está escrito en JavaScript puro, es fácil de entender y resulta excelente para aprender y crear prototipos.</p>
<p><strong>Instalación:</strong></p>
<pre><code class="language-bash">npm install cannon-es
</code></pre>
<p><strong>Configuración básica:</strong></p>
<pre><code class="language-js">import * as CANNON from 'cannon-es'

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

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

// Esfera que cae
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>Bucle del juego:</strong></p>
<pre><code class="language-js">const TIMESTEP = 1 / 60

function animate() {
  world.step(TIMESTEP)
  
  // Sincronizar con Three.js
  mesh.position.copy(sphereBody.position)
  mesh.quaternion.copy(sphereBody.quaternion)
  
  requestAnimationFrame(animate)
}
</code></pre>
<p><strong>Eventos de colisión:</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('¡Impacto fuerte!')
  }
})
</code></pre>
<p><strong>Restricciones:</strong></p>
<pre><code class="language-js">// Restricción de distancia (similar a una cuerda)
const constraint = new CANNON.DistanceConstraint(
  bodyA, bodyB, 
  2 // distancia
)
world.addConstraint(constraint)

// Restricción de bisagra
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>Ventajas:</strong></p>
<ul>
<li>JavaScript puro, sin la complejidad de WASM</li>
<li>Paquete pequeño (~150 KB)</li>
<li>Fácil de aprender y depurar</li>
<li>Funciona en todas partes</li>
<li>Excelente integración con Three.js</li>
<li>Buena documentación</li>
</ul>
<p><strong>Desventajas:</strong></p>
<ul>
<li>Más lento que las alternativas basadas en WASM</li>
<li>Tiene dificultades con muchos cuerpos (&gt;100)</li>
<li>Compatibilidad limitada con mallas trianguladas</li>
<li>Sin CCD integrado (los objetos pueden atravesarse)</li>
<li>Desarrollo menos activo</li>
</ul>
<hr>
<h3>Ammo.js — Toda la potencia de Bullet Physics</h3>
<p>Ammo.js es el motor Bullet Physics compilado a WebAssembly. Ofrece el máximo número de funciones, incluidos cuerpos blandos, vehículos y restricciones avanzadas.</p>
<p><strong>Instalación:</strong></p>
<pre><code class="language-bash">npm install ammo.js
# O usar desde una CDN
</code></pre>
<p><strong>Configuración básica:</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>Cuerpos blandos (tela):</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>Física de vehículos:</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)
  
  // Añadir ruedas
  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))   // Delantera izquierda
  addWheel(true, new Ammo.btVector3(-1, 0, 1.5))  // Delantera derecha
  addWheel(false, new Ammo.btVector3(1, 0, -1.5)) // Trasera izquierda
  addWheel(false, new Ammo.btVector3(-1, 0, -1.5))// Trasera derecha
  
  return vehicle
}
</code></pre>
<p><strong>Ventajas:</strong></p>
<ul>
<li>El conjunto de funciones más completo</li>
<li>Cuerpos blandos, telas y cuerdas</li>
<li>Física avanzada de vehículos</li>
<li>Probado exhaustivamente (se utiliza en muchos juegos AAA)</li>
<li>Altamente configurable
<strong>Desventajas:</strong></li>
<li>API compleja y verbosa</li>
<li>Tamaño de paquete grande</li>
<li>Requiere gestión de memoria (destruir objetos)</li>
<li>Curva de aprendizaje pronunciada</li>
<li>Documentación dispersa</li>
<li>No ofrece determinismo multiplataforma (solo en el mismo dispositivo y con una configuración cuidadosa)</li>
</ul>
<hr>
<h3>Jolt — física 3D moderna con calidad AAA</h3>
<p>Jolt Physics es el motor que impulsa juegos como Horizon Forbidden West, y JoltPhysics.js lo lleva al navegador mediante una adaptación a WASM. Es una excelente opción intermedia entre Rapier y Ammo.js: ofrece más funciones que Rapier (cuerpos blandos, telas y un controlador para vehículos con ruedas) y cuenta con el mantenimiento activo del que carece Ammo.js. El paquete npm es <code>jolt-physics</code>, y existen integraciones listas para usar con React Three Fiber (<code>@react-three/jolt</code>) y Babylon.js.</p>
<pre><code class="language-bash">npm install jolt-physics
</code></pre>
<p>Al igual que Rapier, usa WASM con inicialización asíncrona y una API detallada pero completa que reproduce la interfaz de C++. Elige Jolt cuando necesites funciones comparables a las de Bullet (cuerpos blandos y vehículos), pero prefieras una base de código moderna y mantenida en lugar de Ammo.js.</p>
<hr>
<h3>Oimo.js — ligero y rápido</h3>
<p>Oimo.js es un motor de física 3D ligero. Es ideal para juegos sencillos que no necesitan funciones avanzadas.</p>
<p><strong>Instalación:</strong></p>
<pre><code class="language-bash">npm install oimo
</code></pre>
<p><strong>Configuración básica:</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: fuerza bruta, 2: barrido y poda, 3: árbol de volúmenes
  worldscale: 1,
  random: true,
  gravity: [0, -9.8, 0]
})

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

// Crear una esfera que cae
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>Bucle del juego:</strong></p>
<pre><code class="language-js">function animate() {
  world.step()
  
  // Obtener la posición/rotación
  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>Ventajas:</strong></p>
<ul>
<li>Muy pequeño (~100 KB)</li>
<li>API sencilla</li>
<li>Buen rendimiento en escenas básicas</li>
<li>Compatibilidad integrada con Babylon.js</li>
</ul>
<p><strong>Desventajas:</strong></p>
<ul>
<li>Formas limitadas (solo primitivas)</li>
<li>Sin cuerpos blandos</li>
<li>Documentación escasa</li>
<li>Desarrollo menos activo</li>
<li>Opciones de articulaciones limitadas</li>
</ul>
<hr>
<h2>Motores de física 2D</h2>
<h3>Matter.js — atractivo e intuitivo</h3>
<p>Matter.js es un motor de física 2D rico en funciones, con excelentes herramientas de renderizado y depuración.</p>
<p><strong>Instalación:</strong></p>
<pre><code class="language-bash">npm install matter-js
</code></pre>
<p><strong>Configuración básica:</strong></p>
<pre><code class="language-js">import Matter from 'matter-js'

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

// Crear el motor
const engine = Engine.create()

// Crear el renderizador (opcional, excelente para depurar)
const render = Render.create({
  element: document.body,
  engine: engine,
  options: {
    width: 800,
    height: 600,
    wireframes: false
  }
})

// Crear cuerpos
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
})

// Añadir al mundo
World.add(engine.world, [ground, box, circle])

// Ejecutar
Render.run(render)
Runner.run(Runner.create(), engine)
</code></pre>
<p><strong>Eventos de colisión:</strong></p>
<pre><code class="language-js">Matter.Events.on(engine, 'collisionStart', (event) =&gt; {
  event.pairs.forEach(pair =&gt; {
    console.log('Colisión entre:', pair.bodyA.label, pair.bodyB.label)
  })
})
</code></pre>
<p><strong>Restricciones:</strong></p>
<pre><code class="language-js">// Restricción de anclaje
const pin = Matter.Constraint.create({
  pointA: { x: 400, y: 100 },
  bodyB: box,
  stiffness: 0.9
})

// Resorte entre cuerpos
const spring = Matter.Constraint.create({
  bodyA: box,
  bodyB: circle,
  stiffness: 0.01,
  length: 100
})

World.add(engine.world, [pin, spring])
</code></pre>
<p><strong>Interacción con el ratón:</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>Ventajas:</strong></p>
<ul>
<li>Renderizado predeterminado atractivo</li>
<li>Excelente para aprender y crear prototipos</li>
<li>API intuitiva</li>
<li>Buena documentación</li>
<li>Comunidad activa</li>
<li>Determinista de forma predeterminada: Matter.Runner ahora usa un paso temporal fijo y determinista (el paso temporal no fijo se eliminó en la versión 0.20.0)</li>
</ul>
<p><strong>Desventajas:</strong></p>
<ul>
<li>Sin CCD (los objetos rápidos pueden atravesar otros; usa subpasos para mitigarlo)</li>
<li>Problemas de rendimiento con muchos cuerpos</li>
<li>Sin opción WASM</li>
<li>Precisión limitada en simulaciones complejas</li>
</ul>
<hr>
<h3>Planck.js — Box2D para JavaScript</h3>
<p>Planck.js es una reimplementación completa de Box2D en JavaScript. Ofrece física probada en numerosos juegos y es determinista cuando se usa un paso temporal fijo.</p>
<p><strong>Instalación:</strong></p>
<pre><code class="language-bash">npm install planck
</code></pre>
<p><strong>Configuración básica:</strong></p>
<pre><code class="language-js">import { World, Vec2, Box, Circle, Edge } from 'planck'

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

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

// Crear una caja dinámica
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>Bucle del juego con paso temporal fijo:</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)
  
  // Recorrer todos los cuerpos
  for (let body = world.getBodyList(); body; body = body.getNext()) {
    const pos = body.getPosition()
    const angle = body.getAngle()
    // Actualizar tus sprites...
  }
  
  requestAnimationFrame(gameLoop)
}
</code></pre>
<p><strong>Callbacks de colisión:</strong></p>
<pre><code class="language-js">world.on('begin-contact', (contact) =&gt; {
  const fixtureA = contact.getFixtureA()
  const fixtureB = contact.getFixtureB()
  console.log('Contacto iniciado')
})

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

world.on('pre-solve', (contact, oldManifold) =&gt; {
  // Aquí puedes desactivar el contacto
  // contact.setEnabled(false)
})
</code></pre>
<p><strong>Articulaciones:</strong></p>
<pre><code class="language-js">import { RevoluteJoint, DistanceJoint, PrismaticJoint } from 'planck'

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

// Articulación de distancia (resorte)
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>Ventajas:</strong></p>
<ul>
<li>Determinista con un paso temporal fijo</li>
<li>Bien documentado (se aplica la documentación de Box2D)</li>
<li>Buen rendimiento</li>
<li>Compatibilidad con TypeScript</li>
<li>Tamaño de paquete pequeño</li>
</ul>
<p><strong>Desventajas:</strong></p>
<ul>
<li>El CCD no funciona con articulaciones, por lo que los objetos rápidos conectados mediante articulaciones pueden estirarse</li>
<li>Curva de aprendizaje más pronunciada que Matter.js</li>
<li>Sin renderizador integrado</li>
<li>Particularidades de Box2D (la escala de unidades es importante)</li>
</ul>
<hr>
<h3>p2-es — física 2D flexible</h3>
<p>p2-es es el fork mantenido de p2.js. Es ideal para juegos que necesitan restricciones y mecanismos complejos.</p>
<p><strong>Instalación:</strong></p>
<pre><code class="language-bash">npm install p2-es
</code></pre>
<p><strong>Configuración básica:</strong></p>
<pre><code class="language-js">import * as p2 from 'p2-es'

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

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

// Círculo dinámico
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>Restricciones avanzadas:</strong></p>
<pre><code class="language-js">// Restricción de engranaje
const gear = new p2.GearConstraint(bodyA, bodyB, {
  ratio: 2 // bodyB gira al doble de velocidad
})
world.addConstraint(gear)

// Restricción prismática (deslizador)
const prismatic = new p2.PrismaticConstraint(bodyA, bodyB, {
  localAnchorA: [0, 0],
  localAnchorB: [0, 0],
  localAxisA: [1, 0],
  disableRotationalLock: false
})
world.addConstraint(prismatic)

// Restricción de bloqueo (soldadura)
const lock = new p2.LockConstraint(bodyA, bodyB)
world.addConstraint(lock)
</code></pre>
<p><strong>Materiales de contacto:</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)

// Aplicar a las formas
iceBody.shapes[0].material = ice
rubberBody.shapes[0].material = rubber
</code></pre>
<p><strong>Ventajas:</strong></p>
<ul>
<li>Sistema de restricciones completo</li>
<li>Materiales de contacto</li>
<li>Adecuado para mecanismos y máquinas</li>
<li>Cuerpos en reposo (mejor rendimiento)</li>
<li>Módulos ES compatibles con tree shaking</li>
</ul>
<p><strong>Desventajas:</strong></p>
<ul>
<li>Sin renderizador integrado</li>
<li>Algunas combinaciones de formas no son compatibles</li>
<li>Menos activo que otras alternativas</li>
<li>Lagunas en la documentación</li>
</ul>
<hr>
<h2>Consejos de rendimiento</h2>
<h3>1. Usa un paso temporal fijo</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
  }
  
  // Interpolar para obtener un renderizado fluido
  const alpha = accumulator / TIMESTEP
  // lerp(previousState, currentState, alpha)
}
</code></pre>
<h3>2. Pon en reposo los cuerpos inactivos</h3>
<p>La mayoría de los motores admiten cuerpos en reposo. Activa esta función:</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. Usa formas de colisión sencillas</h3>
<pre><code class="language-js">// BIEN: primitivas sencillas
const sphere = new CANNON.Sphere(1)
const box = new CANNON.Box(new CANNON.Vec3(1, 1, 1))

// EVITA: mallas triangulares complejas para cuerpos dinámicos
const trimesh = new CANNON.Trimesh(vertices, indices) // ¡Lento!
</code></pre>
<h3>4. Reduce las iteraciones del solucionador (con cuidado)</h3>
<pre><code class="language-js">// Cannon-es
world.solver.iterations = 5 // Valor predeterminado: 10

// Rapier: se configura al crear el mundo
</code></pre>
<h3>5. Ejecuta la física en 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
  // Actualizar los objetos de renderizado
}

// 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>Cuándo usar cada motor</h2>
<table>
<thead>
<tr>
<th>Situación</th>
<th>Recomendado</th>
<th>Motivo</th>
</tr>
</thead>
<tbody>
<tr>
<td>Juego 3D para producción</td>
<td><strong>Rapier</strong></td>
<td>El mejor rendimiento, API moderna y vehículos</td>
</tr>
<tr>
<td>Prototipo de juego 3D</td>
<td><strong>Cannon-es</strong></td>
<td>Sencillo y fácil de depurar</td>
</tr>
<tr>
<td>Cuerpos blandos, telas y cuerdas</td>
<td><strong>Ammo.js</strong> o <strong>Jolt</strong></td>
<td>Ambos admiten telas y cuerpos blandos; la adaptación WASM de Jolt (JoltPhysics.js) cuenta con un mantenimiento más activo</td>
</tr>
<tr>
<td>Juego 3D sencillo para navegador</td>
<td><strong>Oimo.js</strong></td>
<td>Pequeño y suficiente</td>
</tr>
<tr>
<td>Game jam de juegos 2D</td>
<td><strong>Matter.js</strong></td>
<td>Configuración rápida y renderizado integrado</td>
</tr>
<tr>
<td>Juego de plataformas preciso</td>
<td><strong>Planck.js</strong></td>
<td>Determinismo con paso temporal fijo y buena documentación</td>
</tr>
<tr>
<td>Mecanismos 2D complejos</td>
<td><strong>p2-es</strong></td>
<td>Sistema de restricciones completo</td>
</tr>
<tr>
<td>Juego 2D donde el rendimiento es crítico</td>
<td><strong>Box2D WASM</strong></td>
<td>La opción 2D más rápida, con CCD real</td>
</tr>
</tbody>
</table>
<hr>
<h2>Integración con renderizadores</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() // cuerpo físico -&gt; malla

function createPhysicsBox(x, y, z) {
  // Física
  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)
  
  // Renderizado
  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) {
  // Física
  const body = Matter.Bodies.rectangle(x, y, width, height)
  Matter.World.add(engine.world, body)
  
  // Renderizado
  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>Errores frecuentes</h2>
<h3>La escala es importante</h3>
<p>Los motores de física funcionan mejor con una escala del mundo real (1 unidad = 1 metro). No uses coordenadas de píxeles directamente.</p>
<pre><code class="language-js">// MAL: usar posiciones en píxeles
const body = Bodies.circle(400, 300, 50) // ¿Radio de 50 píxeles?

// BIEN: usar un factor de escala
const SCALE = 50 // 50 píxeles por metro
const body = Bodies.circle(8, 6, 1) // Radio de 1 metro
// Después, multiplicar por SCALE al renderizar
</code></pre>
<h3>Fugas de memoria con motores WASM</h3>
<p>Libera siempre los cuerpos cuando los elimines:</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>Efecto túnel (objetos que se atraviesan)</h3>
<p>Los objetos que se mueven rápidamente pueden atravesar paredes delgadas. Soluciones:</p>
<pre><code class="language-js">// Rapier: activar CCD
const bodyDesc = RAPIER.RigidBodyDesc.dynamic().setCcdEnabled(true)

// Cannon-es: usar pasos temporales más pequeños o paredes más gruesas
world.step(1/120) // 120 Hz en lugar de 60
</code></pre>
<hr>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/webgl-fundamentals-for-games">Fundamentos de WebGL para desarrolladores de juegos</a></li>
<li><a href="/es/tutorials/web-workers-game-logic">Web Workers para la lógica del juego</a> — descarga los cálculos físicos en un hilo Worker</li>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a> — el patrón de paso de tiempo fijo utilizado por todos los motores de física</li>
<li><a href="/es/guides/web-games-stack-2026">Stack tecnológico para juegos web en 2026</a> — cómo encajan los motores de física en el stack general</li>
<li><a href="/es/guides/web-game-engines-comparison">Comparativa de motores para juegos web</a> — motores con soporte de física integrado</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://rapier.rs/docs/">Documentación de Rapier</a> — documentación oficial y ejemplos de física con Rapier</li>
<li><a href="https://pmndrs.github.io/cannon-es/docs/">Documentación de Cannon-es</a> — referencia de la API y guías</li>
<li><a href="https://brm.io/matter-js/docs/">Documentación de Matter.js</a> — referencia completa de la API</li>
<li><a href="https://piqnt.com/planck.js/">Documentación de Planck.js</a> — Box2D para JavaScript</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[API de gamepad para juegos web]]></title>
            <link>https://app.cinevva.com/es/tutorials/gamepad-api</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/gamepad-api</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Compatibilidad total con gamepads: detección, asignación de botones, sticks analógicos, vibración y gestión de varios mandos. Controles con calidad de consola en la web.]]></description>
            <content:encoded><![CDATA[<h1>API de gamepad para juegos web</h1>
<p>La API de Gamepad ofrece compatibilidad con mandos con calidad de consola para juegos web. Permite usar mandos de Xbox, PlayStation, Switch Pro y mandos genéricos.</p>
<h2>1) Detección de gamepads</h2>
<pre><code class="language-js">window.addEventListener('gamepadconnected', (e) =&gt; {
  console.log('Gamepad connected:', e.gamepad.id)
  console.log('Index:', e.gamepad.index)
  console.log('Buttons:', e.gamepad.buttons.length)
  console.log('Axes:', e.gamepad.axes.length)
})

window.addEventListener('gamepaddisconnected', (e) =&gt; {
  console.log('Gamepad disconnected:', e.gamepad.id)
})
</code></pre>
<h2>2) Consulta del estado del gamepad</h2>
<p>El estado del gamepad debe consultarse periódicamente (no se controla mediante eventos):</p>
<pre><code class="language-js">function pollGamepads() {
  const gamepads = navigator.getGamepads()
  
  for (const gamepad of gamepads) {
    if (gamepad) {
      processGamepad(gamepad)
    }
  }
}

// Call in your game loop
function gameLoop() {
  pollGamepads()
  update()
  render()
  requestAnimationFrame(gameLoop)
}
</code></pre>
<h2>3) Asignación estándar de botones</h2>
<p>La asignación «standard» (para la mayoría de los mandos de Xbox y PlayStation):</p>
<pre><code class="language-js">const BUTTONS = {
  A: 0,           // Cross (PS) / A (Xbox)
  B: 1,           // Circle (PS) / B (Xbox)
  X: 2,           // Square (PS) / X (Xbox)
  Y: 3,           // Triangle (PS) / Y (Xbox)
  LB: 4,          // L1 (PS) / LB (Xbox)
  RB: 5,          // R1 (PS) / RB (Xbox)
  LT: 6,          // L2 (PS) / LT (Xbox)
  RT: 7,          // R2 (PS) / RT (Xbox)
  SELECT: 8,      // Share (PS) / Back (Xbox)
  START: 9,       // Options (PS) / Start (Xbox)
  L3: 10,         // Left stick press
  R3: 11,         // Right stick press
  DPAD_UP: 12,
  DPAD_DOWN: 13,
  DPAD_LEFT: 14,
  DPAD_RIGHT: 15,
  HOME: 16,       // PS button / Xbox button
}

const AXES = {
  LEFT_X: 0,
  LEFT_Y: 1,
  RIGHT_X: 2,
  RIGHT_Y: 3,
}
</code></pre>
<h2>4) Lectura de botones y sticks</h2>
<pre><code class="language-js">function processGamepad(gamepad) {
  // Buttons (digital or analog)
  const jump = gamepad.buttons[BUTTONS.A].pressed
  const shoot = gamepad.buttons[BUTTONS.X].pressed
  
  // Triggers (analog 0-1)
  const leftTrigger = gamepad.buttons[BUTTONS.LT].value
  const rightTrigger = gamepad.buttons[BUTTONS.RT].value
  
  // Sticks (analog -1 to 1)
  const leftX = gamepad.axes[AXES.LEFT_X]
  const leftY = gamepad.axes[AXES.LEFT_Y]
  const rightX = gamepad.axes[AXES.RIGHT_X]
  const rightY = gamepad.axes[AXES.RIGHT_Y]
  
  return { jump, shoot, leftTrigger, rightTrigger, leftX, leftY, rightX, rightY }
}
</code></pre>
<h2>5) Gestión de la zona muerta</h2>
<p>Los sticks rara vez permanecen exactamente en 0:</p>
<pre><code class="language-js">function applyDeadzone(value, deadzone = 0.15) {
  if (Math.abs(value) &lt; deadzone) return 0
  
  // Rescale remaining range to 0-1
  const sign = Math.sign(value)
  const adjusted = (Math.abs(value) - deadzone) / (1 - deadzone)
  return sign * adjusted
}

function getStickInput(gamepad) {
  return {
    leftX: applyDeadzone(gamepad.axes[AXES.LEFT_X]),
    leftY: applyDeadzone(gamepad.axes[AXES.LEFT_Y]),
    rightX: applyDeadzone(gamepad.axes[AXES.RIGHT_X]),
    rightY: applyDeadzone(gamepad.axes[AXES.RIGHT_Y]),
  }
}
</code></pre>
<h2>6) Zona muerta radial (mejor para el movimiento 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,
  }
}

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

// Usage
const gamepadManager = new GamepadManager()

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

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

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

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

function update() {
  const players = multiplayer.getActivePlayers()
  
  for (const { index, state } of players) {
    updatePlayer(index, state)
  }
}
</code></pre>
<h2>10) Entrada alternativa y combinada</h2>
<p>Admite tanto el teclado como el gamepad:</p>
<pre><code class="language-js">class InputManager {
  constructor() {
    this.keyboard = new KeyboardInput()
    this.gamepad = new GamepadManager()
  }
  
  poll() {
    this.gamepad.poll()
  }
  
  getInput(playerIndex = 0) {
    const gpState = this.gamepad.getState(playerIndex)
    const kbState = this.keyboard.getState()
    
    // Combine inputs (gamepad takes priority for analog)
    return {
      moveX: gpState?.leftStick.x || (kbState.left ? -1 : kbState.right ? 1 : 0),
      moveY: gpState?.leftStick.y || (kbState.up ? -1 : kbState.down ? 1 : 0),
      jump: gpState?.a || kbState.space,
      shoot: gpState?.x || kbState.z,
      pause: gpState?.start || kbState.escape,
    }
  }
  
  endFrame() {
    this.gamepad.endFrame()
    this.keyboard.endFrame()
  }
}
</code></pre>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/game-input-handling">Gestión de controles de juego</a></li>
<li><a href="/es/tutorials/mobile-friendly-web-games">Juegos web adaptados a dispositivos móviles</a></li>
<li><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a></li>
<li><a href="/es/tutorials/pointer-lock-fps">Pointer Lock para juegos FPS</a> — combina el gamepad con la vista controlada mediante el ratón</li>
<li><a href="/es/guides/co-op-game-design">Diseño de juegos cooperativos</a> — cómo diseñar para varios mandos en modo cooperativo local</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN: API de Gamepad</a> — referencia completa de la API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API">MDN: Uso de la API de Gamepad</a> — guía paso a paso</li>
<li><a href="https://gamepad-tester.com/">Gamepad Tester</a> — prueba las asignaciones de tu mando en el navegador</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Buenas prácticas para integrar juegos mediante iframe]]></title>
            <link>https://app.cinevva.com/es/tutorials/iframe-embedding-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/iframe-embedding-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Integra correctamente juegos web en iframes: seguridad, comunicación, pantalla completa y requisitos de las plataformas. Haz que tu juego pueda integrarse en cualquier lugar.]]></description>
            <content:encoded><![CDATA[<h1>Buenas prácticas para integrar juegos mediante iframe</h1>
<p>Muchas plataformas (<a href="http://Itch.io">Itch.io</a>, Newgrounds, Cinevva o tu propio sitio) integran juegos mediante iframes. Este tutorial explica cómo hacer que tu juego funcione correctamente cuando está integrado.</p>
<h2>1) Configuración básica para la integración</h2>
<p>Tu juego debe funcionar sin necesitar el control total de la página:</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) Detectar el contexto del iframe</h2>
<pre><code class="language-js">const isEmbedded = window.self !== window.top

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

function hideExternalLinks() {
  document.querySelectorAll('a[target=&quot;_blank&quot;]').forEach(link =&gt; {
    link.style.display = 'none'
  })
}
</code></pre>
<h2>3) Permitir los permisos necesarios del iframe</h2>
<p>La página principal controla lo que puede hacer tu iframe. Estos son algunos permisos habituales:</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>Tu juego debe gestionar correctamente la falta de permisos:</p>
<pre><code class="language-js">// Check if fullscreen is available
const canFullscreen = document.fullscreenEnabled || document.webkitFullscreenEnabled

if (!canFullscreen) {
  document.getElementById('fullscreen-btn').style.display = 'none'
}
</code></pre>
<h2>4) Pantalla completa desde un iframe</h2>
<p>La pantalla completa requiere el atributo <code>allow=&quot;fullscreen&quot;</code>:</p>
<pre><code class="language-js">async function requestFullscreen() {
  const elem = document.documentElement
  
  try {
    if (elem.requestFullscreen) {
      await elem.requestFullscreen()
    } else if (elem.webkitRequestFullscreen) {
      await elem.webkitRequestFullscreen()
    }
  } catch (err) {
    // Fullscreen not allowed - show message
    showMessage('La pantalla completa no está disponible cuando el juego está integrado')
  }
}
</code></pre>
<h2>5) Comunicación con la página principal</h2>
<p>Usa <code>postMessage</code> para establecer una comunicación segura entre distintos orígenes:</p>
<p><strong>En tu juego:</strong></p>
<pre><code class="language-js">// Send message to parent
function notifyParent(type, data) {
  if (window.parent !== window) {
    window.parent.postMessage({ type, data, source: 'game' }, '*')
  }
}

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

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

// Notify on game events
function onGameOver(score) {
  notifyParent('gameover', { score })
}
</code></pre>
<p><strong>En la página principal:</strong></p>
<pre><code class="language-js">const iframe = document.getElementById('game-iframe')

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

// Send commands to game
function pauseGame() {
  iframe.contentWindow.postMessage({ type: 'pause' }, '*')
}
</code></pre>
<h2>6) Gestionar el foco</h2>
<p>Los iframes pueden perder el foco e impedir que funcione la entrada del teclado:</p>
<pre><code class="language-js">// Auto-focus canvas when clicked
canvas.addEventListener('click', () =&gt; {
  canvas.focus()
})

// Make canvas focusable
canvas.tabIndex = 1

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

// Request focus from parent
function requestFocus() {
  notifyParent('requestFocus', {})
}
</code></pre>
<h2>7) Tamaño adaptable del contenido integrado</h2>
<p>Gestiona las distintas dimensiones de integración:</p>
<pre><code class="language-js">function handleResize() {
  const width = window.innerWidth
  const height = window.innerHeight
  
  // Adjust UI based on size
  if (width &lt; 400 || height &lt; 300) {
    enableCompactUI()
  } else {
    enableFullUI()
  }
  
  // Scale game appropriately
  resizeCanvas(width, height)
}

window.addEventListener('resize', handleResize)
handleResize()
</code></pre>
<h2>8) Indicador de carga para contenido integrado</h2>
<p>Muestra algo de inmediato:</p>
<pre><code class="language-js">// Inline in HTML for instant display
const loadingHTML = `
  &lt;div id=&quot;loading&quot; style=&quot;
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #1a1a2e;
    color: #fff;
    font-family: sans-serif;
  &quot;&gt;
    &lt;div&gt;
      &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
      &lt;p&gt;Cargando...&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
`

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

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

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

window.addEventListener('message', (event) =&gt; {
  const { type } = event.data
  if (!allowedCommands.includes(type)) return
  
  // Handle command...
})
</code></pre>
<h2>Lista de comprobación para las pruebas</h2>
<ul>
<li><strong>Pruebas locales</strong>: Usa un servidor HTTP sencillo, no <code>file://</code></li>
<li><strong>Entre distintos orígenes</strong>: Prueba el juego integrado en un iframe real</li>
<li><strong>Permisos</strong>: Haz pruebas con un entorno aislado restringido</li>
<li><strong>Foco</strong>: Prueba el teclado después de hacer clic fuera del iframe</li>
<li><strong>Cambio de tamaño</strong>: Prueba distintas dimensiones de integración</li>
<li><strong>Dispositivos móviles</strong>: Prueba los controles táctiles en un contexto integrado</li>
</ul>
<pre><code class="language-html">&lt;!-- Test embed page --&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body style=&quot;background: #333; padding: 20px;&quot;&gt;
  &lt;h1 style=&quot;color: #fff;&quot;&gt;Prueba de integración&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>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/mobile-friendly-web-games">Juegos web adaptados a dispositivos móviles</a></li>
<li><a href="/es/creators">Para creadores</a></li>
<li><a href="/es/tutorials/coop-coep-sharedarraybuffer">COOP/COEP y SharedArrayBuffer</a> — cabeceras entre distintos orígenes que afectan a la integración mediante iframe</li>
<li><a href="/es/guides/itch-io-launch-guide">Cómo lanzar tu juego en itch.io</a> — <a href="http://itch.io">itch.io</a> integra de forma nativa los juegos de navegador mediante iframes</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe">MDN: elemento iframe</a> — referencia completa de los atributos de iframe</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy">MDN: política de permisos</a> — controla qué funciones pueden usar los iframes</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage">MDN: API postMessage</a> — comunicación entre el iframe y la página principal</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Guardar y cargar el estado del juego con IndexedDB]]></title>
            <link>https://app.cinevva.com/es/tutorials/indexeddb-game-saves</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/indexeddb-game-saves</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Partidas guardadas persistentes con IndexedDB: almacena el progreso, los ajustes y grandes volúmenes de datos. Patrones asíncronos que no bloquearán tu juego.]]></description>
            <content:encoded><![CDATA[<h1>Guardar y cargar el estado del juego con IndexedDB</h1>
<p>IndexedDB es la mejor opción para almacenar datos persistentes de juegos en el navegador. Gestiona grandes cantidades de datos, funciona sin conexión y no bloquea el hilo principal.</p>
<h2>1) ¿Por qué usar IndexedDB en lugar de localStorage?</h2>
<table>
<thead>
<tr>
<th>Característica</th>
<th>localStorage</th>
<th>IndexedDB</th>
</tr>
</thead>
<tbody>
<tr>
<td>Límite de almacenamiento</td>
<td>~5-10 MB</td>
<td>50+ MB (a menudo, GB)</td>
</tr>
<tr>
<td>Tipos de datos</td>
<td>Solo cadenas</td>
<td>Objetos, blobs y arrays</td>
</tr>
<tr>
<td>Asíncrono</td>
<td>No (bloquea)</td>
<td>Sí</td>
</tr>
<tr>
<td>Consultas indexadas</td>
<td>No</td>
<td>Sí</td>
</tr>
</tbody>
</table>
<p>Para los juegos, IndexedDB casi siempre es la opción adecuada.</p>
<h2>2) Abrir una base de datos</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) Guardar el estado del juego</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) Cargar el estado del juego</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) Enumerar todas las partidas guardadas</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) Eliminar una partida guardada</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) Una clase GameStorage completa</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) Almacenar datos binarios (texturas y audio)</h2>
<p>IndexedDB admite Blobs y 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) Patrón de guardado automático</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) Gestión de errores y alternativas</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) Evitar que se eliminen las partidas guardadas</h2>
<p>No se garantiza que los datos de IndexedDB permanezcan indefinidamente. De forma predeterminada, un origen utiliza almacenamiento de «mejor esfuerzo», y el navegador puede eliminarlo cuando el disco se llena o, en Safari/WebKit, después de un periodo sin interacción del usuario con tu sitio. En el caso de las partidas guardadas, esos son precisamente los datos que no quieres perder.</p>
<p>Solicita almacenamiento persistente para que el navegador no borre tus datos sin una acción explícita del usuario:</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>Los navegadores deciden si lo conceden en función de señales de interacción, como cuánto interactúa el usuario con tu sitio o si está instalado como PWA, así que no des por hecho que siempre funcionará. También puedes comprobar cuánto espacio tienes disponible antes de escribir partidas guardadas grandes o recursos en caché:</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>Ambas API necesitan un contexto seguro (HTTPS o localhost).</p>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/pwa-offline-games">PWA para juegos sin conexión</a></li>
<li><a href="/es/tutorials/service-workers-caching">Service workers para almacenar juegos en caché</a></li>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/streaming-asset-loading">Carga de recursos por streaming</a> — uso de IndexedDB como caché de recursos</li>
<li><a href="/es/tutorials/analytics-web-games">Analítica para juegos web</a> — seguimiento de patrones de guardado y carga para comprender el comportamiento de los jugadores</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">MDN: API de IndexedDB</a> — referencia completa de la API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB">MDN: Uso de IndexedDB</a> — guía paso a paso</li>
<li><a href="https://github.com/jakearchibald/idb">Biblioteca idb</a> — un pequeño contenedor de IndexedDB basado en promesas</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Juegos web compatibles con móviles]]></title>
            <link>https://app.cinevva.com/es/tutorials/mobile-friendly-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/mobile-friendly-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Haz que los juegos web funcionen en móviles: controles táctiles, rendimiento, viewport, batería y diseño adaptable. Llega a la plataforma de juegos más grande.]]></description>
            <content:encoded><![CDATA[<h1>Juegos web compatibles con móviles</h1>
<p>Los dispositivos móviles son la mayor plataforma de juegos. Con unos pocos ajustes, puedes hacer que tu juego web funcione de maravilla en teléfonos y tabletas.</p>
<h2>1) Configuración del viewport</h2>
<p>Evita el zoom y garantiza un escalado correcto:</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 a pantalla completa</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 dimensionas la interfaz según la altura del viewport en lugar de usar <code>height: 100%</code>, es preferible utilizar las unidades dinámicas de viewport <code>dvh</code>, <code>svh</code> y <code>lvh</code> en vez de <code>vh</code>. Estas tienen en cuenta la barra de herramientas retráctil y expandible del navegador móvil, algo que <code>100vh</code> ignora (en iOS, <code>100vh</code> corresponde a la altura con la barra de herramientas oculta, por lo que la parte inferior del diseño queda recortada). Estas unidades alcanzaron el estado Baseline Widely Available y son compatibles con Chrome/Edge 108+, Firefox 101+ y Safari/iOS Safari 15.4+, aproximadamente el 92 % de los usuarios.</p>
<h2>3) Gestión de eventos táctiles</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 tu juego también debe funcionar con un ratón o un lápiz óptico, considera usar Pointer Events (<code>pointerdown</code>/<code>pointermove</code>/<code>pointerup</code>) en lugar de eventos táctiles. MDN los recomienda ahora como un modelo de entrada único e independiente del dispositivo: una sola ruta de código gestiona el tacto, el ratón y el lápiz, mientras que la entrada multitáctil sigue funcionando mediante <code>pointerId</code> (el equivalente a <code>touch.identifier</code> utilizado arriba). Los eventos táctiles siguen siendo perfectamente adecuados para juegos exclusivamente táctiles.</p>
<h2>4) Joystick virtual</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) Botones táctiles</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) Gestión de la orientación</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) Optimización del rendimiento</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) Consideraciones sobre la batería</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 de pantalla completa</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>Una salvedad: la API de pantalla completa no funciona con elementos generales en el iPhone. Safari para iOS solo permite la pantalla completa para elementos de vídeo nativos (mediante el método no estándar <code>webkitEnterFullscreen</code>), por lo que <code>documentElement.requestFullscreen()</code> se rechazará en el iPhone. El bloque <code>try/catch</code> anterior gestiona esto correctamente, así que diseña tu juego para que se pueda jugar sin una pantalla completa real en el iPhone y trata la pantalla completa como una mejora para el iPad, Android y los equipos de escritorio compatibles. La llamada a <code>screen.orientation.lock()</code> tiene la misma limitación en el iPhone, así que no dependas de ella.</p>
<h2>10) Lista de comprobación para las pruebas</h2>
<p>Antes de publicar:</p>
<ul>
<li><strong>Respuesta táctil</strong> — Prueba en dispositivos reales, no solo en emuladores</li>
<li><strong>Distintos tamaños de pantalla</strong> — Teléfono, tableta, iPad Pro</li>
<li><strong>Ambas orientaciones</strong> — O bloquea una de ellas</li>
<li><strong>Dispositivos de gama baja</strong> — Prueba en teléfonos de hace 2 o 3 años</li>
<li><strong>Redes lentas</strong> — Prueba con una simulación de 3G</li>
<li><strong>Safari para iOS</strong> — Tiene peculiaridades con el audio y la pantalla completa</li>
<li><strong>Chrome para Android</strong> — El navegador más común</li>
<li><strong>Interrupciones</strong> — Llamadas telefónicas y notificaciones</li>
<li><strong>Presión de memoria</strong> — ¿Se recupera después de permanecer en una pestaña en segundo plano?</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>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/game-input-handling">Gestión de la entrada del juego</a></li>
<li><a href="/es/tutorials/responsive-game-canvas">Canvas adaptable para juegos</a></li>
<li><a href="/es/tutorials/pwa-offline-games">PWA para juegos sin conexión</a></li>
<li><a href="/es/tutorials/web-audio-api-games">API Web Audio para juegos</a> — gestión de las restricciones de audio de iOS</li>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a> — optimización de los tiempos de carga para conexiones móviles</li>
<li><a href="/es/tutorials/iframe-embedding-games">Incrustación de juegos mediante iframe</a> — consideraciones específicas para incrustaciones en móviles</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN: Eventos táctiles</a> — gestión multitáctil</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API">MDN: API de orientación de pantalla</a> — bloqueo de la orientación</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">MDN: API de pantalla completa</a> — activación de la pantalla completa en móviles</li>
<li><a href="https://web.dev/learn/design/">web.dev: Diseño adaptable</a> — patrones adaptables aplicables a la interfaz de los juegos</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Renderizado de pixel art en juegos web]]></title>
            <link>https://app.cinevva.com/es/tutorials/pixel-art-rendering</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/pixel-art-rendering</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Renderiza pixel art correctamente: escalado nítido, sin desenfoque, paletas de colores y animación. Haz que el estilo retro de tu juego parezca intencionado.]]></description>
            <content:encoded><![CDATA[<h1>Renderizado de pixel art en juegos web</h1>
<p>El pixel art se ve increíble cuando se renderiza correctamente y terrible cuando está borroso. Este tutorial explica las técnicas necesarias para conseguir gráficos de píxeles nítidos y auténticos.</p>
<h2>1) El problema del desenfoque</h2>
<p>De forma predeterminada, los navegadores suavizan las imágenes al escalarlas. Para el pixel art, esto no es adecuado:</p>
<pre><code class="language-css">/* CSS solution */
canvas {
  image-rendering: pixelated;
  image-rendering: crisp-edges; /* Firefox fallback */
}
</code></pre>
<h2>2) Ajustes del contexto del canvas</h2>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

// Disable smoothing for crisp pixels
ctx.imageSmoothingEnabled = false
</code></pre>
<p>Esto debe configurarse después de cada cambio de tamaño del canvas o cambio de contexto.</p>
<h2>3) Escalado con números enteros</h2>
<p>El pixel art se ve mejor con escalas enteras (1x, 2x, 3x, 4x):</p>
<pre><code class="language-js">const GAME_WIDTH = 160
const GAME_HEIGHT = 144 // Game Boy resolution

function resize() {
  const maxScale = Math.floor(Math.min(
    window.innerWidth / GAME_WIDTH,
    window.innerHeight / GAME_HEIGHT
  ))
  const scale = Math.max(1, maxScale)
  
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
  canvas.style.width = GAME_WIDTH * scale + 'px'
  canvas.style.height = GAME_HEIGHT * scale + 'px'
  
  ctx.imageSmoothingEnabled = false
}
</code></pre>
<h2>4) Dibujar sprites en posiciones enteras</h2>
<p>Las posiciones de subpíxel provocan desenfoque:</p>
<pre><code class="language-js">// Bad: causes blur
ctx.drawImage(sprite, 10.5, 20.3)

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

// Or round to nearest
ctx.drawImage(sprite, Math.round(player.x), Math.round(player.y))
</code></pre>
<h2>5) Animación con hojas 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()
  }
}

// Usage
const player = new PixelSprite(spriteSheet, 16, 16)
player.addAnimation('idle', [0, 1], 2)
player.addAnimation('walk', [2, 3, 4, 5], 8)
player.addAnimation('jump', [6], 1)
</code></pre>
<h2>6) Intercambio de paletas</h2>
<p>Una técnica clásica para cambiar los colores de los sprites:</p>
<pre><code class="language-js">function createPaletteSwap(image, oldColors, newColors) {
  const canvas = document.createElement('canvas')
  canvas.width = image.width
  canvas.height = image.height
  const ctx = canvas.getContext('2d')
  
  ctx.drawImage(image, 0, 0)
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  const data = imageData.data
  
  for (let i = 0; i &lt; data.length; i += 4) {
    const r = data[i], g = data[i + 1], b = data[i + 2]
    
    for (let j = 0; j &lt; oldColors.length; j++) {
      const old = oldColors[j]
      if (r === old.r &amp;&amp; g === old.g &amp;&amp; b === old.b) {
        const newC = newColors[j]
        data[i] = newC.r
        data[i + 1] = newC.g
        data[i + 2] = newC.b
        break
      }
    }
  }
  
  ctx.putImageData(imageData, 0, 0)
  return canvas
}

// Create enemy variant
const blueEnemy = createPaletteSwap(
  enemySprite,
  [{ r: 255, g: 0, b: 0 }],    // Red
  [{ r: 0, g: 100, b: 255 }]   // Blue
)
</code></pre>
<h2>7) Renderizado de mapas de teselas</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) Efectos de contorno</h2>
<p>Añade contornos a los sprites para mejorar su visibilidad:</p>
<pre><code class="language-js">function drawWithOutline(ctx, image, x, y, outlineColor = '#000') {
  const offsets = [[-1, 0], [1, 0], [0, -1], [0, 1]]
  
  // Draw outline
  ctx.globalCompositeOperation = 'source-over'
  for (const [ox, oy] of offsets) {
    ctx.drawImage(image, Math.round(x + ox), Math.round(y + oy))
  }
  
  // Apply color to outline
  ctx.globalCompositeOperation = 'source-in'
  ctx.fillStyle = outlineColor
  ctx.fillRect(x - 1, y - 1, image.width + 2, image.height + 2)
  
  // Draw sprite on top
  ctx.globalCompositeOperation = 'source-over'
  ctx.drawImage(image, Math.round(x), Math.round(y))
}
</code></pre>
<h2>9) Sacudida de pantalla</h2>
<p>Un efecto clásico para mejorar las sensaciones del juego:</p>
<pre><code class="language-js">const camera = {
  x: 0,
  y: 0,
  shakeIntensity: 0,
  shakeDuration: 0,
  
  shake(intensity, duration) {
    this.shakeIntensity = intensity
    this.shakeDuration = duration
  },
  
  update(dt) {
    if (this.shakeDuration &gt; 0) {
      this.shakeDuration -= dt
    }
  },
  
  getOffset() {
    if (this.shakeDuration &lt;= 0) return { x: 0, y: 0 }
    
    return {
      x: Math.round((Math.random() - 0.5) * 2 * this.shakeIntensity),
      y: Math.round((Math.random() - 0.5) * 2 * this.shakeIntensity),
    }
  }
}

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

// On hit:
camera.shake(4, 0.2) // 4 pixel intensity, 0.2 seconds
</code></pre>
<h2>10) Restricciones de la paleta de colores</h2>
<p>El pixel art auténtico utiliza paletas limitadas:</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>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a></li>
<li><a href="/es/tutorials/responsive-game-canvas">Canvas adaptable para juegos</a></li>
<li><a href="/es/tutorials/streaming-asset-loading">Carga progresiva de recursos</a></li>
<li><a href="/es/guides/game-assets-guide">Dónde encontrar recursos gratuitos para juegos</a> — hojas de sprites, paquetes de pixel art y fuentes de recursos 2D</li>
<li><a href="/es/tutorials/text-rendering-games">Renderizado de texto en juegos</a> — fuentes de mapa de bits que combinan con el estilo de tu pixel art</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bloqueo del puntero para juegos FPS]]></title>
            <link>https://app.cinevva.com/es/tutorials/pointer-lock-fps</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/pointer-lock-fps</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Implementa el control de cámara con el ratón mediante la API Pointer Lock: captura el cursor, procesa el movimiento y crea controles FPS para juegos web.]]></description>
            <content:encoded><![CDATA[<h1>Bloqueo del puntero para juegos FPS</h1>
<p>La API Pointer Lock captura el cursor del ratón y permite controlar la cámara con fluidez al estilo FPS. Es esencial para shooters en primera y tercera persona.</p>
<h2>1) Solicitar el bloqueo del puntero</h2>
<p>El bloqueo del puntero requiere una acción del usuario:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')

canvas.addEventListener('click', () =&gt; {
  canvas.requestPointerLock()
})
</code></pre>
<h2>Entrada directa del ratón (desactivar la aceleración)</h2>
<p>De forma predeterminada, el navegador aplica la aceleración del ratón del sistema operativo a <code>movementX</code>/<code>movementY</code>, por lo que un movimiento rápido y uno lento que recorran la misma distancia producen una rotación distinta de la cámara. Para apuntar en un FPS, normalmente se busca lo contrario: la misma distancia física debe girar siempre la cámara en la misma medida. Solicita movimiento directo sin aceleración pasando <code>unadjustedMovement: true</code> a <code>requestPointerLock()</code>:</p>
<pre><code class="language-js">canvas.addEventListener('click', async () =&gt; {
  try {
    await canvas.requestPointerLock({ unadjustedMovement: true })
  } catch (err) {
    // unadjustedMovement no es compatible aquí; usar deltas ajustados por el sistema operativo
    await canvas.requestPointerLock()
  }
})
</code></pre>
<p>La versión moderna de <code>requestPointerLock()</code> devuelve una promesa que se resuelve si la operación tiene éxito y se rechaza si falla, por eso la llamada anterior está envuelta en <code>try/catch</code>. Chrome y Edge admiten <code>unadjustedMovement</code> desde la versión 88, y Safari desde la 18.4 (marzo de 2025), que también fue la versión que corrigió <code>requestPointerLock</code> para que devolviera una promesa. Los navegadores antiguos que no devuelven una promesa siguen funcionando aquí porque <code>await</code> admite un valor que no sea una promesa y la opción desconocida simplemente se ignora; por tanto, conserva siempre la llamada alternativa a <code>requestPointerLock()</code> sin opciones.</p>
<h2>2) Detectar el estado del bloqueo</h2>
<pre><code class="language-js">document.addEventListener('pointerlockchange', () =&gt; {
  if (document.pointerLockElement === canvas) {
    console.log('Puntero bloqueado')
    game.mouseLocked = true
  } else {
    console.log('Puntero desbloqueado')
    game.mouseLocked = false
  }
})

document.addEventListener('pointerlockerror', () =&gt; {
  console.error('No se pudo bloquear el puntero')
})
</code></pre>
<h2>3) Leer el movimiento del ratón</h2>
<p>Mientras esté bloqueado, usa <code>movementX</code> y <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
  
  // Limitar la inclinación para evitar que la cámara dé la vuelta
  camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})
</code></pre>
<h2>4) Clase de cámara FPS</h2>
<pre><code class="language-js">class FPSCamera {
  constructor() {
    this.position = { x: 0, y: 1.7, z: 0 } // Altura de los ojos
    this.yaw = 0      // Rotación izquierda/derecha
    this.pitch = 0    // Rotación arriba/abajo
    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()
    
    // Mover solo en el plano XZ
    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) Movimiento con 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) Controlador FPS completo</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)
    
    // Movimiento horizontal
    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
    
    // Salto
    if (this.input.jump &amp;&amp; this.onGround) {
      this.velocity.y = this.jumpSpeed
      this.onGround = false
    }
    
    // Gravedad
    if (!this.onGround) {
      this.velocity.y += this.gravity * dt
    }
    
    // Aplicar la velocidad
    this.camera.position.x += this.velocity.x * dt
    this.camera.position.y += this.velocity.y * dt
    this.camera.position.z += this.velocity.z * dt
    
    // Colisión sencilla con el suelo
    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) Retícula</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
  
  // Arriba
  ctx.beginPath()
  ctx.moveTo(cx, cy - gap)
  ctx.lineTo(cx, cy - gap - size)
  ctx.stroke()
  
  // Abajo
  ctx.beginPath()
  ctx.moveTo(cx, cy + gap)
  ctx.lineTo(cx, cy + gap + size)
  ctx.stroke()
  
  // Izquierda
  ctx.beginPath()
  ctx.moveTo(cx - gap, cy)
  ctx.lineTo(cx - gap - size, cy)
  ctx.stroke()
  
  // Derecha
  ctx.beginPath()
  ctx.moveTo(cx + gap, cy)
  ctx.lineTo(cx + gap + size, cy)
  ctx.stroke()
}
</code></pre>
<h2>8) Disparar con los botones del ratón</h2>
<pre><code class="language-js">document.addEventListener('mousedown', (e) =&gt; {
  if (!document.pointerLockElement) return
  
  if (e.button === 0) {
    // Clic izquierdo: disparo principal
    weapon.fire()
  } else if (e.button === 2) {
    // Clic derecho: apuntar con la mira
    weapon.aimDownSights(true)
  }
})

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

// Evitar el menú contextual
canvas.addEventListener('contextmenu', (e) =&gt; e.preventDefault())
</code></pre>
<h2>9) Ajustes de sensibilidad</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())
  }
}

// Aplicar en el controlador del ratón
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) Aviso para salir</h2>
<p>Muestra instrucciones para salir del bloqueo del puntero:</p>
<pre><code class="language-js">function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '&lt;p&gt;Pulsa ESC para liberar el ratón&lt;/p&gt;'
    ui.style.opacity = '0.5'
    setTimeout(() =&gt; ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '&lt;p&gt;Haz clic para jugar&lt;/p&gt;'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)
</code></pre>
<p>Hay algo que debes saber sobre el aviso para salir: cuando el jugador pulsa ESC para abandonar el bloqueo del puntero, el navegador exige una nueva acción del usuario (un clic) antes de poder bloquearlo de nuevo. Por eso existe la capa «Haz clic para jugar». Sin embargo, si tu propio código llama a <code>document.exitPointerLock()</code> —por ejemplo, para abrir un menú del juego—, no se necesita una nueva acción para volver a bloquearlo después, por lo que puedes reactivarlo mediante código cuando se cierre el menú. Pulsar ESC repetidamente también puede hacer que el navegador rechace un nuevo bloqueo hasta que el usuario realice una acción más deliberada, así que no intentes volver a bloquearlo automáticamente en <code>pointerlockchange</code>.</p>
<h2>Consideraciones sobre iframes</h2>
<p>El bloqueo del puntero en iframes requiere el atributo <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>Comprueba si el bloqueo del puntero está disponible:</p>
<pre><code class="language-js">if (!document.pointerLockElement &amp;&amp; !('requestPointerLock' in canvas)) {
  showMessage('La captura del ratón no está disponible')
}
</code></pre>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/game-input-handling">Gestión de entradas del juego</a></li>
<li><a href="/es/tutorials/gamepad-api">API Gamepad</a></li>
<li><a href="/es/tutorials/webgl-fundamentals-for-games">Fundamentos de WebGL</a></li>
<li><a href="/es/tutorials/webxr-basics">Fundamentos de WebXR</a> — controles de RV para experiencias en primera persona</li>
<li><a href="/es/tutorials/webgpu-getting-started">Primeros pasos con WebGPU</a> — renderizado moderno con GPU para juegos FPS</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API">MDN: API Pointer Lock</a> — referencia completa de la API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX">MDN: MouseEvent.movementX</a> — lectura de los deltas del ratón</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[PWA para juegos web sin conexión]]></title>
            <link>https://app.cinevva.com/es/tutorials/pwa-offline-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/pwa-offline-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Haz que tu juego funcione sin conexión con una PWA: manifiesto, service worker, estrategias de almacenamiento en caché y avisos de instalación. Juega en cualquier lugar y momento.]]></description>
            <content:encoded><![CDATA[<h1>PWA para juegos web sin conexión</h1>
<p>Las aplicaciones web progresivas permiten que los jugadores instalen tu juego y jueguen sin conexión. Este tutorial muestra cómo añadir compatibilidad con PWA a un juego web.</p>
<h2>1) Manifiesto de la aplicación web</h2>
<p>Crea <code>manifest.json</code>:</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;Mi juego increíble&quot;,
  &quot;short_name&quot;: &quot;JuegoIncreíble&quot;,
  &quot;description&quot;: &quot;Un juego increíble al que puedes jugar sin conexión&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>Enlázalo en tu 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 básico</h2>
<p>Crea <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) Registrar el 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) Priorizar la caché con actualización desde la red</h2>
<p>Es mejor para juegos: carga rápida con actualizaciones en segundo plano:</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) Caché con versiones para las actualizaciones</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) Gestionar las actualizaciones del juego</h2>
<p>Avisa a los jugadores cuando haya una actualización 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;¡Juego actualizado! Recarga la página para obtener la última versión.&lt;/p&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Recargar&lt;/button&gt;
  `
  banner.className = 'update-banner'
  document.body.appendChild(banner)
}
</code></pre>
<h2>7) Detección del estado sin conexión</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) Aviso de instalación</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>En iOS y iPadOS, el evento <code>beforeinstallprompt</code> nunca se activa, por lo que el botón de instalación anterior solo aparece en navegadores basados en Chromium. Los usuarios de iPhone y iPad pueden instalar la aplicación tocando Compartir y después «Añadir a pantalla de inicio». Hay un cambio de 2026 que conviene conocer: a partir de Safari 26 (iOS 26 / iPadOS 26), todos los sitios añadidos a la pantalla de inicio se abren como aplicaciones web de forma predeterminada, con la opción «Abrir como aplicación web» activada por defecto. Ya no existe un requisito de instalabilidad en iOS, aunque incluir un manifiesto y un service worker sigue ofreciendo una experiencia sin conexión mucho mejor.</p>
<h2>9) Sincronización en segundo plano para las clasificaciones</h2>
<p>Importante sobre la compatibilidad: la API Background Sync solo está implementada en navegadores basados en Chromium (Chrome, Edge, Opera y Samsung Internet). Firefox y Safari (incluido iOS) no la admiten, así que detecta siempre si la función está disponible y conserva una alternativa que envíe las puntuaciones la próxima vez que la aplicación se cargue normalmente.</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) Probar 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; Casilla Offline</li>
</ul>
<p><strong>Auditoría de Lighthouse:</strong></p>
<ul>
<li>Ejecuta la auditoría de PWA</li>
<li>Comprueba la instalabilidad</li>
<li>Comprueba el funcionamiento sin conexión</li>
</ul>
<p><strong>Pruebas en un dispositivo real:</strong></p>
<ul>
<li>Instálala en la pantalla de inicio del teléfono</li>
<li>Activa el modo avión</li>
<li>Prueba todas las funciones sin conexión</li>
</ul>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/service-workers-caching">Service workers para almacenar el juego en caché</a></li>
<li><a href="/es/tutorials/indexeddb-game-saves">Partidas guardadas con IndexedDB</a></li>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/mobile-friendly-web-games">Juegos web adaptados a dispositivos móviles</a> — controles táctiles y gestión del viewport para juegos PWA</li>
<li><a href="/es/tutorials/streaming-asset-loading">Carga progresiva de recursos</a> — estrategias de carga compatibles con el almacenamiento en caché del service worker</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps">MDN: Aplicaciones web progresivas</a> — documentación completa sobre PWA</li>
<li><a href="https://web.dev/learn/pwa/">web.dev: Aprende PWA</a> — ruta de aprendizaje de Google sobre PWA</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Manifest">MDN: Manifiesto de aplicación web</a> — referencia del archivo de manifiesto</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Canvas de juego adaptable que escala correctamente]]></title>
            <link>https://app.cinevva.com/es/tutorials/responsive-game-canvas</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/responsive-game-canvas</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Haz que el canvas de tu juego sea adaptable: escalado perfecto por píxeles, barras negras y gestión de eventos de cambio de tamaño sin tirones.]]></description>
            <content:encoded><![CDATA[<h1>Canvas de juego adaptable</h1>
<p>Un canvas que no escala correctamente se ve borroso o se rompe en distintos tamaños de pantalla. He depurado este problema más veces de las que puedo contar, así que déjame ahorrarte el esfuerzo.</p>
<p><strong>Prueba distintos modos de escalado:</strong></p>
<ResponsiveDemo />
<h2>El problema principal</h2>
<p>El canvas tiene dos tamaños que son fáciles de confundir. El tamaño CSS controla lo grande que aparece en pantalla. La resolución interna controla cuántos píxeles dibujas realmente. Si no coinciden, se produce desenfoque o distorsión.</p>
<p>En una pantalla Retina, tu CSS puede indicar 800x600, pero la pantalla tiene en realidad 1600x1200 píxeles físicos. Si la resolución interna de tu canvas solo es de 800x600, el navegador la amplía y todo se ve borroso.</p>
<h2>Configuración adaptable básica</h2>
<p>Así puedes hacer que un canvas ocupe toda la ventana y se vea nítido:</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
  
  // Ajusta la resolución interna para que coincida con los píxeles físicos
  canvas.width = width * dpr
  canvas.height = height * dpr
  
  // Ajusta el tamaño CSS para que coincida con los píxeles lógicos
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  
  // Escala el contexto para poder dibujar en píxeles lógicos
  ctx.scale(dpr, dpr)
}

window.addEventListener('resize', resize)
resize()
</code></pre>
<p>La línea <code>ctx.scale(dpr, dpr)</code> es el truco. Después, puedes dibujar en la posición (100, 100) y aparecerá en el lugar correcto independientemente del DPR. El canvas se renderiza internamente a 200x200 en una pantalla 2x, pero tu código no necesita saberlo.</p>
<p>Un detalle importante: <code>ctx.scale</code> es acumulativo. Si llamas varias veces a la función de cambio de tamaño, el escalado seguirá aumentando. Puedes restablecer primero la transformación con <code>ctx.setTransform(1, 0, 0, 1, 0, 0)</code> antes de escalar o aplicar el escalado una sola vez.</p>
<h2>Relación de aspecto fija con barras negras</h2>
<p>Muchos juegos necesitan una relación de aspecto específica. No querrás que tu juego 16:9 se estire para llenar un monitor ultrapanorámico 21:9. Las barras negras conservan la relación de aspecto.</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) {
    // La ventana es más ancha que el juego; añade barras a los lados
    height = windowHeight
    width = height * ASPECT
  } else {
    // La ventana es más alta que el juego; añade barras arriba y abajo
    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'
  
  // La resolución interna permanece fija
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
}
</code></pre>
<p>Para que se vean las barras negras, establece en negro el fondo del cuerpo de la página:</p>
<pre><code class="language-css">body {
  margin: 0;
  background: #000;
  overflow: hidden;
}
</code></pre>
<h2>Escalado entero para pixel art</h2>
<p>El pixel art se ve nítido con escalas enteras (1x, 2x, 3x) y borroso con escalas fraccionarias como 2,7x. Incluso con <code>image-rendering: pixelated</code>, una escala de 2,5x hace que algunos píxeles tengan 2 píxeles de pantalla de ancho y otros 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
  
  // Encuentra la mayor escala entera que quepa
  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>La contrapartida es que tu juego no llenará la pantalla exactamente. En una pantalla de 1920x1080 con un juego de 320x180, obtendrás un escalado de 5x (1600x900) con barras negras alrededor de los bordes. Algunos jugadores lo prefieren; otros quieren que el juego llene la pantalla aunque se vea ligeramente borroso.</p>
<h2>Escalado del canvas WebGL</h2>
<p>Para WebGL, además del tamaño del canvas debes actualizar el viewport:</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 // Se ha redimensionado; quizá debas actualizar las matrices de proyección
  }
  return false
}
</code></pre>
<p>Llama a esta función al inicio del bucle de renderizado, no solo en los eventos de cambio de tamaño. Algunos navegadores cambian el DPR cuando arrastras una ventana entre monitores, y conviene detectar ese cambio.</p>
<h2>Limitación de eventos de cambio de tamaño</h2>
<p>Los eventos de cambio de tamaño se disparan rápidamente mientras el usuario arrastra el borde de la ventana. Si tu función de cambio de tamaño es costosa, se producirán tirones.</p>
<pre><code class="language-js">let resizeTimeout
window.addEventListener('resize', () =&gt; {
  clearTimeout(resizeTimeout)
  resizeTimeout = setTimeout(resize, 100)
})
</code></pre>
<p>El retraso de 100 ms hace que el cambio de tamaño solo se produzca cuando el usuario deja de arrastrar. Para gestionarlo con mayor fluidez durante el arrastre, usa ResizeObserver:</p>
<pre><code class="language-js">const observer = new ResizeObserver(entries =&gt; {
  resize()
})
observer.observe(document.body)
</code></pre>
<p>ResizeObserver se activa con menos frecuencia y está mejor optimizado por el navegador.</p>
<h2>Pantalla completa</h2>
<p>La API Fullscreen permite ocupar toda la pantalla:</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>Debes llamar a <code>requestFullscreen</code> en respuesta a un gesto del usuario, como un clic. De lo contrario, los navegadores lo bloquean. Y recuerda cambiar el tamaño al entrar o salir del modo de pantalla completa, ya que el espacio disponible cambia.</p>
<p>Una salvedad sobre las plataformas: a mediados de 2026, la API Fullscreen aún no permite mostrar elementos a pantalla completa en Safari para iPhone. requestFullscreen funciona en navegadores de escritorio y en iPadOS, pero en iPhone no es compatible con elementos arbitrarios como un canvas (solo funciona la reproducción nativa de vídeo a pantalla completa). Detecta la función con <code>if (canvas.requestFullscreen)</code> y ofrece una alternativa adecuada, por ejemplo, ajustando el canvas para que ocupe el viewport visible o sugiriendo a los usuarios que añadan el juego a su pantalla de inicio como PWA.</p>
<h2>Unirlo todo</h2>
<p>Esta clase gestiona todos los casos habituales:</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>Úsala así:</p>
<pre><code class="language-js">const responsive = new ResponsiveCanvas(document.getElementById('game'), {
  width: 320,
  height: 180,
  pixelArt: true,
  letterbox: true
})

// En el código de tu juego, usa responsive.width y responsive.height
</code></pre>
<h2>Problemas habituales</h2>
<p>Algunas cosas que suelen causar problemas:</p>
<p><strong>El canvas se borra al cambiar su tamaño.</strong> Cuando estableces <code>canvas.width</code> o <code>canvas.height</code>, el canvas se borra. Si no vuelves a dibujarlo en cada fotograma, tendrás que hacerlo después de cambiar su tamaño.</p>
<p><strong>El DPR puede cambiar.</strong> Si el usuario arrastra la ventana del navegador desde una pantalla Retina a una pantalla que no sea Retina, el DPR cambia. Compruébalo en el bucle de renderizado, no solo al cambiar el tamaño.</p>
<p><strong>Peculiaridades del viewport en Safari para iOS.</strong> La altura del viewport cambia cuando la barra de direcciones aparece o desaparece. Usa <code>window.visualViewport</code> para obtener medidas más precisas en dispositivos móviles.</p>
<p><strong>El estado del contexto se restablece.</strong> Al establecer las dimensiones del canvas, se restablece el estado del contexto, incluidas las transformaciones, los estilos de relleno e <code>imageSmoothingEnabled</code>. Vuelve a aplicarlos después de cambiar el tamaño.</p>
<h2>Más recursos</h2>
<p><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a> explica los fundamentos de los bucles de actualización y renderizado.</p>
<p><a href="/es/tutorials/pixel-art-rendering">Renderizado de pixel art</a> profundiza en cómo crear gráficos de píxeles nítidos.</p>
<p><a href="/es/tutorials/mobile-friendly-web-games">Juegos web adaptados a dispositivos móviles</a> trata la entrada táctil y los problemas del viewport.</p>
<p><a href="/es/tutorials/webgl-fundamentals-for-games">Fundamentos de WebGL</a> explica la configuración del viewport y del canvas para contextos WebGL.</p>
<p><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a> explica cómo optimizar el tamaño del canvas para mejorar el rendimiento.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Service workers para almacenar en caché los recursos del juego]]></title>
            <link>https://app.cinevva.com/es/tutorials/service-workers-caching</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/service-workers-caching</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Almacena en caché los recursos del juego con service workers: estrategias, versionado y actualizaciones. Haz que tu juego se cargue al instante en visitas posteriores.]]></description>
            <content:encoded><![CDATA[<h1>Service workers para almacenar en caché los recursos del juego</h1>
<p>Los service workers te permiten controlar cómo se almacenan en caché y se sirven los recursos. En los juegos, esto permite cargas instantáneas en visitas posteriores y compatibilidad sin conexión.</p>
<h2>1) Conceptos básicos de los service workers</h2>
<p>Crea <code>sw.js</code> en la raíz:</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>Regístralo:</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) Estrategia de caché primero (la mejor para los recursos del juego)</h2>
<p>Carga desde la caché y recurre a la red solo si el recurso no está almacenado:</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) Precarga en caché de recursos críticos</h2>
<p>El evento de instalación es perfecto para la precarga en caché:</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) Estrategia de red primero para contenido dinámico</h2>
<p>Usa la estrategia de red primero para los datos que cambian, como las clasificaciones y los datos de los jugadores:</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) Servir contenido obsoleto mientras se revalida</h2>
<p>Devuelve inmediatamente el contenido en caché y actualízalo en segundo plano:</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) Versionado y limpieza de la caché</h2>
<p>Elimina las cachés antiguas durante la activación:</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) Cachés separadas por tipo</h2>
<p>Organiza las cachés para gestionarlas mejor:</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) Solicitudes de rango para audio y vídeo</h2>
<p>Hay algo que debes saber desde el principio: la API de caché no almacena respuestas parciales. Si una respuesta 206 Partial Content llega a <code>cache.put()</code>, se produce el error <code>TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported</code>. Por tanto, el patrón para los archivos multimedia en caché siempre es el mismo: precarga (o descarga) el archivo completo como una respuesta 200 normal y, después, genera tú mismo la respuesta 206 extrayendo los bytes correspondientes de la caché cada vez que llegue una solicitud con una cabecera Range. Si prefieres no implementarlo manualmente, el complemento range-requests de Workbox realiza exactamente esa extracción cuando se combina con una estrategia CacheFirst.</p>
<p>Gestiona correctamente las solicitudes de rango, necesarias para desplazarse por el 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) Notificación de actualización</h2>
<p>Avisa a los jugadores cuando haya una nueva versión disponible:</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;¡Hay una nueva versión disponible!&lt;/span&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Actualizar&lt;/button&gt;
  `
  document.body.appendChild(banner)
}
</code></pre>
<h2>10) Service worker completo para un juego</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>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/pwa-offline-games">PWA para juegos sin conexión</a></li>
<li><a href="/es/tutorials/streaming-asset-loading">Carga de recursos mediante streaming</a></li>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/web-audio-api-games">API Web Audio para juegos</a> — almacenamiento en caché de recursos de audio para jugar sin conexión</li>
<li><a href="/es/tutorials/indexeddb-game-saves">Partidas guardadas con IndexedDB</a> — conservación del estado del juego junto con los recursos almacenados en caché</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">MDN: API de Service Worker</a> — referencia completa de la API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Cache">MDN: API de caché</a> — referencia de la interfaz Cache</li>
<li><a href="https://web.dev/learn/pwa/service-workers/">web.dev: Service workers</a> — estrategias y patrones de almacenamiento en caché</li>
<li><a href="https://developer.chrome.com/docs/workbox/">Workbox</a> — biblioteca de Google con herramientas para service workers</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Publica un juego web que cargue rápido (una lista de comprobación práctica)]]></title>
            <link>https://app.cinevva.com/es/tutorials/ship-web-game-fast</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/ship-web-game-fast</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Una lista de comprobación pragmática y paso a paso para lograr que los juegos web carguen rápidamente: límites, compresión, almacenamiento en caché, formatos de recursos y mediciones de rendimiento.]]></description>
            <content:encoded><![CDATA[<h1>Publica un juego web que cargue rápido (una lista de comprobación práctica)</h1>
<p>Si los jugadores no pueden <strong>empezar a jugar en cuestión de segundos</strong>, se marchan. Este tutorial es una lista de comprobación práctica que puedes aplicar a casi cualquier stack para juegos web (Canvas/WebGL/WebGPU, Pixi/Three/PlayCanvas, motor propio, etc.).</p>
<h2>1) Define primero los límites (o no podrás optimizar)</h2>
<p>Elige límites que se ajusten a tu dispositivo objetivo. Un buen punto de partida para una experiencia de «juego instantáneo»:</p>
<ul>
<li><strong>JS+CSS enviados</strong>: menos de ~300–500 KB con gzip (cuanto menos, mejor)</li>
<li><strong>Recursos críticos antes de la primera interacción</strong>: menos de ~2–5 MB comprimidos</li>
<li><strong>Tiempo hasta la primera interacción</strong>: menos de ~3–5 segundos en un móvil de gama media</li>
</ul>
<p>Anótalos y trátalos como contratos de API.</p>
<h2>2) Mide lo que importa</h2>
<p>En Chrome DevTools:</p>
<ul>
<li>Usa <strong>Performance</strong> para capturar una sesión e inspeccionar las tareas largas.</li>
<li>Usa <strong>Network</strong> para comprobar:
<ul>
<li>el tamaño transferido frente al tamaño descomprimido</li>
<li>las cabeceras de caché</li>
<li>si los recursos se transmiten progresivamente o llegan como un único bloque enorme</li>
</ul>
</li>
</ul>
<p>En términos del juego, registra:</p>
<ul>
<li>El tiempo hasta que «<strong>el jugador puede moverse</strong>»</li>
<li>El tiempo hasta el «<strong>primer fotograma significativo</strong>»</li>
<li>La «<strong>latencia de la primera interacción</strong>» (entrada → fotograma)</li>
</ul>
<p>Tu concepto de «latencia de la primera interacción» ahora se corresponde con una métrica estándar: <strong>Interaction to Next Paint (INP)</strong> sustituyó a First Input Delay como Core Web Vital en marzo de 2024 y mide el tiempo completo desde la entrada hasta el siguiente repintado en todas las interacciones, no solo en la primera. Intenta mantener el INP por debajo de 200 ms en el percentil 75, un punto de referencia externo útil para las cifras de capacidad de respuesta que ya registras.</p>
<h2>3) Comprime todo lo que puedas</h2>
<p>Asegúrate de que tu alojamiento sirva:</p>
<ul>
<li><strong>Brotli</strong> (<code>br</code>) para recursos de texto (JS/CSS/HTML/WASM) cuando esté disponible</li>
<li><strong>gzip</strong> como alternativa</li>
</ul>
<p>Si tu proveedor de alojamiento y CDN lo permiten, <strong>Zstandard</strong> (<code>zstd</code>) es ahora una tercera opción para <code>Content-Encoding</code>. Está disponible en Chrome y Edge 123+, Firefox 126+ y Safari 26.3+ (aproximadamente el 80 % de los usuarios), y puede alcanzar relaciones de compresión similares a las de Brotli con un menor coste de CPU en el servidor, lo que resulta útil para cargas grandes de WASM y JSON. Los navegadores solo anuncian <code>Accept-Encoding: zstd</code> mediante HTTPS, así que mantén Brotli y gzip configurados como alternativas para todos los demás.</p>
<p>En las compilaciones de WebAssembly, la compresión suele marcar la diferencia entre una carga «instantánea» y una que «nunca termina».</p>
<h2>4) No descargues todo el mundo por adelantado</h2>
<p>Divide tu juego en:</p>
<ul>
<li><strong>Arranque</strong>: cargador mínimo + entrada + primera escena</li>
<li><strong>Núcleo</strong>: sistemas principales de juego</li>
<li><strong>Opcional</strong>: niveles adicionales, elementos cosméticos, texturas de alta resolución, audio adicional, etc.</li>
</ul>
<p>Carga el contenido «Opcional» solo después de que:</p>
<ul>
<li>el jugador ya pueda jugar, o</li>
<li>sepas que quiere ese contenido (por ejemplo, porque ha llegado a un menú o nivel)</li>
</ul>
<h2>5) Elige los formatos de recursos adecuados</h2>
<p>Para la mayoría de los juegos web:</p>
<ul>
<li><strong>Imágenes</strong>: prioriza <strong>WebP</strong> (o <strong>AVIF</strong> si puedes aceptar una codificación más lenta) para interfaces y fondos.</li>
<li><strong>Audio</strong>: prioriza <strong>Ogg Vorbis</strong> para uso general; prueba <strong>AAC</strong> para los ecosistemas de Apple.</li>
<li><strong>Vídeo</strong>: prioriza <strong>MP4/H.264</strong> por compatibilidad y <strong>WebM</strong> cuando sea compatible y ocupe menos.</li>
</ul>
<p>Ten en cuenta el coste de decodificación: una «descarga más pequeña» puede seguir siendo «más lenta de decodificar».</p>
<h2>6) Mantén tu primera escena pequeña y determinista</h2>
<p>Evita lo siguiente en tu primera escena interactiva:</p>
<ul>
<li>avalanchas de compilación de shaders</li>
<li>cargas de texturas enormes</li>
<li>generación procedural que bloquee el hilo principal</li>
<li>análisis síncrono de bloques JSON de varios megabytes</li>
</ul>
<p>En concreto para las texturas, distribuye <strong>KTX2 con la supercompresión Basis Universal</strong> en lugar de PNG o JPEG. Permanece comprimido en la GPU después de cargarse (se transcodifica a BC7 en ordenadores de escritorio o a ASTC en móviles durante la carga), lo que reduce la VRAM entre 4 y 8 veces y hace que las cargas sean mucho menos costosas que decodificar un PNG a tamaño completo y enviar los píxeles sin comprimir a la GPU.</p>
<p>Si tienes que realizar tareas pesadas, hazlo:</p>
<ul>
<li>de forma incremental a lo largo de varios fotogramas, o</li>
<li>en un Worker (cuando sea posible)</li>
</ul>
<h2>7) Usa la caché de forma agresiva (pero correcta)</h2>
<p>Usa una caché de larga duración para los recursos identificados por contenido:</p>
<ul>
<li><code>Cache-Control: public, max-age=31536000, immutable</code></li>
</ul>
<p>Para los puntos de entrada HTML, usa una caché de corta duración para poder desplegar actualizaciones de forma segura.</p>
<h2>8) Haz que los «fallos» sean rápidos y comprensibles</h2>
<p>Si algo sale mal, muestra:</p>
<ul>
<li>un mensaje de error claro</li>
<li>un botón para volver a intentarlo</li>
<li>un enlace para informar del problema</li>
</ul>
<p>Los fallos silenciosos destruyen la confianza.</p>
<h2>9) Si usas hilos de Wasm, comprende COOP/COEP</h2>
<p>Si tu compilación depende de <code>SharedArrayBuffer</code> (algo habitual en algunas configuraciones de hilos de Wasm), probablemente necesitarás cabeceras de <strong>aislamiento entre orígenes</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>Tienes dos opciones para la cabecera COEP. <code>require-corp</code> es la opción estricta, pero implica que cada recurso de origen cruzado (texturas de CDN, scripts de terceros) debe enviar una cabecera <code>Cross-Origin-Resource-Policy</code> o será bloqueado. <code>Cross-Origin-Embedder-Policy: credentialless</code> también habilita <code>SharedArrayBuffer</code> y el aislamiento entre orígenes, pero carga los recursos de origen cruzado sin credenciales en lugar de exigirles que den su consentimiento mediante CORP. Si obtienes recursos de una CDN que no controlas, <code>credentialless</code> suele ser la opción menos problemática.</p>
<p>Haz pruebas cuanto antes en tu alojamiento real, porque las cabeceras y las integraciones de terceros pueden causar problemas.</p>
<h2>10) Haz que se pueda jugar sin un SDK</h2>
<p>Si tu objetivo es el flujo de trabajo para creadores de Cinevva, procura ofrecer:</p>
<ul>
<li>una única URL que arranque de forma fiable</li>
<li>ningún inicio de sesión obligatorio para comenzar una demo</li>
<li>controles de entrada predecibles</li>
</ul>
<p>Así estarás listo para distribuir tu juego sin integrar SDK adicionales.</p>
<p>Contenido relacionado:</p>
<ul>
<li><a href="/es/creators">Para creadores de juegos</a></li>
<li><a href="/es/ai-content">Política sobre contenido generado por IA</a></li>
<li><a href="/es/tutorials/streaming-asset-loading">Carga de recursos en streaming</a> — patrones de carga progresiva para juegos grandes</li>
<li><a href="/es/tutorials/service-workers-caching">Service workers para almacenar juegos en caché</a> — almacena recursos en caché para que las visitas posteriores carguen al instante</li>
<li><a href="/es/guides/web-game-engines-comparison">Comparativa de motores para juegos web</a> — tamaños de compilación y tiempos de carga entre distintos motores</li>
<li><a href="/es/guides/itch-io-launch-guide">Cómo publicar tu juego en itch.io</a> — optimización de juegos para navegador integrados en <a href="http://itch.io">itch.io</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Carga progresiva de recursos para juegos web]]></title>
            <link>https://app.cinevva.com/es/tutorials/streaming-asset-loading</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/streaming-asset-loading</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Carga los recursos del juego progresivamente: priorización, transmisión, almacenamiento en caché y visualización del progreso. Mantén a los jugadores interesados durante la carga.]]></description>
            <content:encoded><![CDATA[<h1>Carga progresiva de recursos para juegos web</h1>
<p>Los jugadores no esperarán a que termine una descarga de 100 MB. Carga los recursos progresivamente para que puedan jugar mientras el resto se descarga en segundo plano.</p>
<h2>1) La estrategia de carga</h2>
<p>Divide los recursos en niveles:</p>
<ol>
<li><strong>Críticos</strong> — Necesarios para mostrar la primera pantalla (&lt; 1 MB)</li>
<li><strong>Jugabilidad</strong> — Necesarios para jugar (&lt; 10 MB)</li>
<li><strong>Mejoras</strong> — Opcionales (se cargan en segundo plano)</li>
</ol>
<h2>2) Cargador de recursos básico</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) Carga con indicador de progreso</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>Ten en cuenta que, cuando el servidor comprime la respuesta (con gzip o brotli, como hacen de forma predeterminada la mayoría de las CDN), <code>Content-Length</code> representa el tamaño comprimido, mientras que los fragmentos que lees ya están descomprimidos. Esto hace que <code>received / total</code> supere el 100 %. Para obtener una barra de progreso precisa a nivel de bytes con respuestas comprimidas, sirve el recurso sin comprimir, envía tu propio encabezado con el tamaño sin comprimir o recurre al evento <code>onprogress</code> de <code>XMLHttpRequest</code>, que informa del progreso real de la transferencia.</p>
<h2>4) Carga por lotes con progreso general</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
}

// Uso
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(`Cargando: ${Math.round(progress * 100)}% (${name})`)
})
</code></pre>
<h2>5) Cargador con cola de prioridad</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()
  }
}

// Uso
const loader = new PriorityLoader()
loader.add('critical.png', 10)  // Cargar primero
loader.add('optional.png', 1)   // Cargar después
</code></pre>
<h2>6) Carga diferida de niveles</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) {
    // Cargar solo lo necesario para este nivel
    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)
    // El recolector de basura puede liberar los recursos
  }
}
</code></pre>
<h2>7) Transmisión de archivos grandes</h2>
<p>Para archivos grandes (modelos 3D, audio), transmítelos y procésalos de forma incremental:</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)
  }
}

// Para audio: usar Media Source Extensions
// Para 3D: procesar los datos de la malla a medida que llegan
</code></pre>
<h2>8) Almacenamiento en caché de los recursos cargados</h2>
<p>Combínalo con IndexedDB para disponer de una caché persistente:</p>
<pre><code class="language-js">class CachedLoader {
  constructor() {
    this.memCache = new Map()
    this.dbName = 'AssetCache'
  }
  
  async loadImage(url) {
    // Comprobar la memoria
    if (this.memCache.has(url)) return this.memCache.get(url)
    
    // Comprobar IndexedDB
    const cached = await this.getFromDB(url)
    if (cached) {
      const img = await this.blobToImage(cached)
      this.memCache.set(url, img)
      return img
    }
    
    // Descargar y almacenar en caché
    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étodos de IndexedDB...
}
</code></pre>
<h2>9) Patrón de pantalla de carga</h2>
<pre><code class="language-js">class LoadingScreen {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.progress = 0
    this.message = 'Cargando...'
  }
  
  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)
    
    // Barra de progreso
    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)
    
    // Texto
    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) Buenas prácticas</h2>
<ul>
<li><strong>Muestra algo de inmediato</strong> — aunque solo sea una imagen estática</li>
<li><strong>Carga primero lo que está visible</strong> — las texturas de la vista actual</li>
<li><strong>Usa marcadores de posición</strong> — imágenes de baja resolución que se sustituyan por otras de mayor calidad</li>
<li><strong>Precarga el siguiente nivel</strong> — mientras el jugador aún está jugando</li>
<li><strong>Gestiona los errores correctamente</strong> — lógica de reintentos y alternativas</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>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/service-workers-caching">Service workers para almacenar juegos en caché</a></li>
<li><a href="/es/tutorials/indexeddb-game-saves">Partidas guardadas en IndexedDB</a></li>
<li><a href="/es/tutorials/web-workers-game-logic">Web Workers para la lógica del juego</a> — decodifica y procesa recursos fuera del hilo principal</li>
<li><a href="/es/guides/game-assets-guide">Dónde encontrar recursos gratuitos para juegos</a> — fuentes de modelos 3D, texturas y audio para transmitir</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">MDN: API Fetch</a> — transmisión de respuestas y ReadableStream</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream">MDN: ReadableStream</a> — procesamiento de fragmentos de datos transmitidos</li>
<li><a href="https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html">Especificación de glTF 2.0</a> — el formato estándar para transmitir recursos 3D</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Renderizado de texto en juegos web]]></title>
            <link>https://app.cinevva.com/es/tutorials/text-rendering-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/text-rendering-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Renderiza texto en juegos: texto de Canvas, fuentes de mapa de bits, fuentes SDF y rendimiento. Desde etiquetas sencillas hasta sistemas de diálogo.]]></description>
            <content:encoded><![CDATA[<h1>Renderizado de texto en juegos web</h1>
<p>El texto está presente en todas partes en los juegos: puntuaciones, diálogos, menús y cifras de daño. Este tutorial explica distintos enfoques y cuándo usar cada uno.</p>
<h2>1) Fundamentos del texto en Canvas</h2>
<p>El enfoque más sencillo:</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 necesitas un espaciado más estrecho o más amplio para títulos y etiquetas del HUD, el contexto de Canvas ahora admite directamente <code>letterSpacing</code> y <code>wordSpacing</code>. Ambos aceptan cadenas de longitud CSS y alcanzaron la compatibilidad Baseline entre navegadores en 2025, por lo que ya no es necesario dibujar los glifos uno por uno para simular el kerning:</p>
<pre><code class="language-js">ctx.font = '24px sans-serif'
ctx.letterSpacing = '2px'
ctx.wordSpacing = '6px'
ctx.fillText('GAME OVER', 100, 100)
</code></pre>
<p>Restablécelos a <code>'0px'</code> cuando termines, ya que permanecen en el contexto igual que <code>font</code> y <code>fillStyle</code>.</p>
<h2>2) Texto con contorno</h2>
<pre><code class="language-js">function drawTextWithOutline(ctx, text, x, y, fillColor, outlineColor, outlineWidth = 2) {
  ctx.font = '24px Arial'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  
  ctx.strokeStyle = outlineColor
  ctx.lineWidth = outlineWidth
  ctx.strokeText(text, x, y)
  
  ctx.fillStyle = fillColor
  ctx.fillText(text, x, y)
}

drawTextWithOutline(ctx, 'GAME OVER', 400, 300, '#fff', '#000', 3)
</code></pre>
<h2>3) Texto con sombra</h2>
<pre><code class="language-js">function drawTextWithShadow(ctx, text, x, y, color, shadowOffset = 2) {
  ctx.font = '32px sans-serif'
  ctx.textAlign = 'left'
  ctx.textBaseline = 'top'
  
  // Shadow
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillText(text, x + shadowOffset, y + shadowOffset)
  
  // Main text
  ctx.fillStyle = color
  ctx.fillText(text, x, y)
}
</code></pre>
<h2>4) Renderizado de fuentes de mapa de bits</h2>
<p>Para juegos de pixel art o para conseguir un aspecto uniforme entre plataformas:</p>
<pre><code class="language-js">class BitmapFont {
  constructor(image, charWidth, charHeight, chars) {
    this.image = image
    this.charWidth = charWidth
    this.charHeight = charHeight
    this.chars = chars
  }
  
  draw(ctx, text, x, y, scale = 1) {
    const w = this.charWidth * scale
    const h = this.charHeight * scale
    
    for (let i = 0; i &lt; text.length; i++) {
      const charIndex = this.chars.indexOf(text[i].toUpperCase())
      if (charIndex === -1) continue
      
      const sx = (charIndex % 16) * this.charWidth
      const sy = Math.floor(charIndex / 16) * this.charHeight
      
      ctx.drawImage(
        this.image,
        sx, sy, this.charWidth, this.charHeight,
        Math.round(x + i * w), Math.round(y), w, h
      )
    }
  }
  
  measureText(text, scale = 1) {
    return text.length * this.charWidth * scale
  }
}

// Usage
const font = new BitmapFont(fontImage, 8, 8, 
  'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?-+:; '
)
font.draw(ctx, 'SCORE: 1000', 10, 10, 2)
</code></pre>
<h2>5) Texto prerenderizado para mejorar el rendimiento</h2>
<p>Para texto estático, renderízalo una vez y reutilízalo:</p>
<pre><code class="language-js">function createTextImage(text, font, color) {
  const tempCanvas = document.createElement('canvas')
  const tempCtx = tempCanvas.getContext('2d')
  
  tempCtx.font = font
  const metrics = tempCtx.measureText(text)
  
  tempCanvas.width = Math.ceil(metrics.width)
  tempCanvas.height = parseInt(font) * 1.5
  
  tempCtx.font = font
  tempCtx.fillStyle = color
  tempCtx.textBaseline = 'top'
  tempCtx.fillText(text, 0, 0)
  
  return tempCanvas
}

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

// Draw many times (fast)
ctx.drawImage(titleImage, x, y)
</code></pre>
<h2>6) Efecto de máquina de escribir</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) Texto con ajuste de línea</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) Sistema de cuadros de diálogo</h2>
<pre><code class="language-js">class DialogueBox {
  constructor(x, y, width, height) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
    this.padding = 16
    this.lineHeight = 24
    this.text = ''
    this.displayedChars = 0
    this.charsPerSecond = 30
    this.timer = 0
  }
  
  show(text) {
    this.text = text
    this.displayedChars = 0
    this.timer = 0
  }
  
  update(dt) {
    if (this.displayedChars &lt; this.text.length) {
      this.timer += dt
      const charsToAdd = Math.floor(this.timer * this.charsPerSecond)
      this.displayedChars = Math.min(this.displayedChars + charsToAdd, this.text.length)
      this.timer = this.timer % (1 / this.charsPerSecond)
    }
  }
  
  skip() {
    this.displayedChars = this.text.length
  }
  
  isComplete() {
    return this.displayedChars &gt;= this.text.length
  }
  
  draw(ctx) {
    // Box background
    ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'
    ctx.fillRect(this.x, this.y, this.width, this.height)
    
    // Border
    ctx.strokeStyle = '#fff'
    ctx.lineWidth = 2
    ctx.strokeRect(this.x, this.y, this.width, this.height)
    
    // Text
    ctx.font = '18px sans-serif'
    ctx.fillStyle = '#fff'
    ctx.textBaseline = 'top'
    
    const displayText = this.text.slice(0, this.displayedChars)
    const maxWidth = this.width - this.padding * 2
    const lines = wrapText(ctx, displayText, maxWidth)
    
    for (let i = 0; i &lt; lines.length; i++) {
      ctx.fillText(
        lines[i],
        this.x + this.padding,
        this.y + this.padding + i * this.lineHeight
      )
    }
    
    // Continue indicator
    if (this.isComplete()) {
      ctx.fillText('▼', this.x + this.width - 24, this.y + this.height - 24)
    }
  }
}
</code></pre>
<h2>9) Cifras de daño flotantes</h2>
<pre><code class="language-js">class FloatingText {
  constructor(text, x, y, color = '#fff') {
    this.text = text
    this.x = x
    this.y = y
    this.color = color
    this.vy = -60
    this.lifetime = 1
    this.age = 0
  }
  
  update(dt) {
    this.y += this.vy * dt
    this.vy *= 0.95
    this.age += dt
  }
  
  draw(ctx) {
    const alpha = 1 - (this.age / this.lifetime)
    ctx.font = 'bold 20px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillStyle = this.color
    ctx.globalAlpha = alpha
    ctx.fillText(this.text, Math.round(this.x), Math.round(this.y))
    ctx.globalAlpha = 1
  }
  
  isExpired() {
    return this.age &gt;= this.lifetime
  }
}

// Manager
const floatingTexts = []

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

function updateFloatingTexts(dt) {
  for (let i = floatingTexts.length - 1; i &gt;= 0; i--) {
    floatingTexts[i].update(dt)
    if (floatingTexts[i].isExpired()) {
      floatingTexts.splice(i, 1)
    }
  }
}
</code></pre>
<h2>10) Texto WebGL (fuentes SDF)</h2>
<p>Para juegos WebGL, las fuentes de campos de distancia con signo (SDF) se escalan con nitidez:</p>
<pre><code class="language-js">// SDF shader fragment
const sdfFragmentShader = `
  precision mediump float;
  
  uniform sampler2D u_texture;
  uniform vec4 u_color;
  varying vec2 v_texCoord;
  
  void main() {
    float distance = texture2D(u_texture, v_texCoord).a;
    float alpha = smoothstep(0.4, 0.6, distance);
    gl_FragColor = vec4(u_color.rgb, u_color.a * alpha);
  }
`

// Libraries like msdf-bmfont-xml can generate SDF font atlases
</code></pre>
<p>En concreto, para escenas de three.js, <code>troika-three-text</code> es una buena opción. En lugar de precalcular un atlas, analiza archivos <code>.ttf</code>/<code>.otf</code>/<code>.woff</code> y genera los glifos SDF sobre la marcha en un web worker. Además, se encarga del kerning, las ligaduras y los sistemas de escritura de derecha a izquierda.</p>
<p>Para la mayoría de los juegos 2D con Canvas, las fuentes de mapa de bits o el texto de Canvas prerenderizado funcionan bien.</p>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a></li>
<li><a href="/es/tutorials/pixel-art-rendering">Renderizado de pixel art</a></li>
<li><a href="/es/tutorials/webgl-fundamentals-for-games">Fundamentos de WebGL</a></li>
<li><a href="/es/tutorials/responsive-game-canvas">Canvas adaptable para juegos</a> — cómo escalar el texto según la resolución y los PPP del Canvas</li>
<li><a href="/es/guides/game-assets-guide">Dónde encontrar recursos gratuitos para juegos</a> — fuentes de tipografías y recursos de interfaz de usuario</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Tone.js para audio de juegos]]></title>
            <link>https://app.cinevva.com/es/tutorials/tone-js-game-audio</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/tone-js-game-audio</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Crea sonidos de juego satisfactorios con Tone.js: efectos de sonido sintetizados, música dinámica y audio reactivo que responde a la jugabilidad.]]></description>
            <content:encoded><![CDATA[<h1>Tone.js para audio de juegos</h1>
<p>Tone.js funciona sobre la API Web Audio y facilita el uso práctico de audio sintetizado. En lugar de cargar archivos de audio, generas sonidos mediante código. Esto significa cero tiempo de carga, variaciones infinitas y sonidos capaces de reaccionar a la jugabilidad en tiempo real.</p>
<p><strong>Pruébalo (haz clic en los botones para escuchar sonidos de juego sintetizados):</strong></p>
<ToneDemo />
<h2>Por qué usar Tone.js en lugar de archivos de audio</h2>
<p>Los archivos de audio funcionan bien para música y sonidos complejos. Sin embargo, para los efectos de sonido de un juego, la síntesis ofrece ventajas reales. Puedes variar el tono y la duración en cada reproducción para que los sonidos no resulten repetitivos. Puedes generar sonidos al instante según el estado del juego. Además, evitas por completo las solicitudes de red.</p>
<p>La desventaja es que el audio sintetizado consume más CPU que reproducir un archivo. En juegos sencillos, esto no supone un problema. En juegos complejos, puedes combinar ambos métodos.</p>
<h2>Configuración</h2>
<p>Carga Tone.js desde una CDN o mediante 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>O con 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>El problema de la reproducción automática</h2>
<p>Los navegadores bloquean el audio hasta que el usuario interactúa. Tone.js se encarga de ello, pero debes llamar a <code>Tone.start()</code> mediante una acción del usuario:</p>
<pre><code class="language-js">document.addEventListener('click', async () =&gt; {
  await Tone.start()
  console.log('Audio listo')
}, { once: true })
</code></pre>
<p>Sin esto, tu AudioContext permanece suspendido y no se reproduce nada.</p>
<h2>Tu primer sintetizador</h2>
<p>Un sintetizador genera sonido. El más sencillo es <code>Tone.Synth</code>:</p>
<pre><code class="language-js">const synth = new Tone.Synth().toDestination()

// Reproducir una nota
synth.triggerAttackRelease('C4', '8n')
</code></pre>
<p><code>triggerAttackRelease</code> reproduce una nota durante un tiempo determinado. <code>'C4'</code> es el do central. <code>'8n'</code> es una corchea al tempo actual.</p>
<p>El sintetizador tiene un oscilador (la fuente del sonido) y una envolvente (cómo aparece y desaparece el sonido). Puedes personalizar ambos:</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>Los tipos de oscilador incluyen <code>sine</code> (puro y suave), <code>triangle</code> (suave), <code>sawtooth</code> (vibrante) y <code>square</code> (hueco y retro).</p>
<h2>Cómo crear un sistema de efectos de sonido</h2>
<p>Para un juego, conviene utilizar varios sintetizadores para distintos tipos de sonido. Este es un patrón que funciona 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
  
  // Control de volumen maestro
  sfxGain = new Tone.Gain(0.7).toDestination()
  
  // Sintetizador suave para sonidos melódicos
  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)
  
  // Subgrave para impactos profundos
  subSynth = new Tone.Synth({
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.3,
      sustain: 0.1,
      release: 0.3,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // Ruido para añadir textura
  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> permite reproducir varias notas a la vez. Un <code>Synth</code> normal es monofónico.</p>
<h2>Ejemplos de sonidos de juego</h2>
<p>Estos son algunos sonidos habituales en juegos y cómo crearlos:</p>
<p><strong>Recoger una moneda</strong>: las notas ascendentes transmiten una sensación de recompensa:</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>Saltar</strong>: un barrido ascendente rápido:</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>Golpe/daño</strong>: tono descendente con ruido:</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>Muerte</strong>: notas descendentes y tristes:</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>Cómo añadir efectos</h2>
<p>Los efectos enriquecen los sonidos. Encadénalos entre el sintetizador y el destino:</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)

// Cadena: sintetizador -&gt; filtro -&gt; reverberación -&gt; ganancia -&gt; destino
filter.connect(reverb)
reverb.connect(gain)
gain.toDestination()

const synth = new Tone.Synth().connect(filter)
</code></pre>
<p>El filtro de paso bajo elimina las frecuencias altas estridentes. La reverberación aporta espacio. La ganancia controla el volumen.</p>
<h2>Limitación de frecuencia</h2>
<p>Si tu juego activa muchos sonidos rápidamente, estos pueden superponerse y crear un caos sonoro. Añade una limitación de frecuencia:</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>Música de fondo dinámica</h2>
<p>Para crear música que reaccione a la jugabilidad, utiliza un sistema basado en pulsos:</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()
  
  // Bajo en el primer pulso
  if (beatInMeasure === 0) {
    bassSynth.triggerAttackRelease(chord.root, '4n', now, 0.6)
  }
  
  // Bombo: solo cuando la energía es alta
  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)
}

// Los eventos del juego afectan a la energía
function onEnemyKill() {
  energy = Math.min(1, energy + 0.1)
}
</code></pre>
<p>La intensidad de la música cambia según lo que sucede en el juego.</p>
<h2>Percusión con MembraneSynth y NoiseSynth</h2>
<p>Para la percusión, utiliza sintetizadores especializados:</p>
<pre><code class="language-js">// Bombo
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')

// Caja
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')

// Charles
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>Limpieza</h2>
<p>Libera los sintetizadores cuando termines de usarlos para recuperar recursos:</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>Errores comunes</h2>
<p><strong>No esperar a Tone.start()</strong>: el audio no se reproducirá hasta que el contexto se inicie mediante una interacción del usuario.</p>
<p><strong>Crear sintetizadores para cada sonido</strong>: crea los sintetizadores una sola vez durante la inicialización y reutilízalos. Crear otros nuevos cada vez provoca fugas de memoria.</p>
<p><strong>Demasiados sonidos superpuestos</strong>: utiliza la limitación de frecuencia y la eliminación de duplicados para evitar una mezcla de audio confusa.</p>
<p><strong>Frecuencias estridentes</strong>: utiliza filtros de paso bajo y tipos de oscilador más suaves (<code>sine</code>, <code>triangle</code>) para crear sonidos que no molesten a los jugadores.</p>
<h2>Más recursos</h2>
<p><a href="/es/tutorials/web-audio-api-games">API Web Audio para juegos</a> explica la API subyacente y cómo cargar archivos de audio.</p>
<p>La <a href="https://tonejs.github.io/">documentación de Tone.js</a> contiene la referencia completa de la API.</p>
<p><a href="/es/guides/game-assets-guide">Dónde encontrar recursos gratuitos para juegos</a> incluye proveedores de audio y efectos de sonido como Freesound.</p>
<p><a href="/es/tutorials/mobile-friendly-web-games">Juegos web adaptados a móviles</a> explica las restricciones de audio de iOS y los patrones de desbloqueo mediante interacción táctil.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web Audio API para juegos]]></title>
            <link>https://app.cinevva.com/es/tutorials/web-audio-api-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/web-audio-api-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Implementa el audio de tus juegos con Web Audio API: efectos de sonido, música, audio espacial y mezcla. Patrones prácticos que funcionan.]]></description>
            <content:encoded><![CDATA[<h1>Web Audio API para juegos</h1>
<p>Un buen audio transforma un juego. Un juego silencioso parece carente de vida. Web Audio API te ofrece efectos de sonido de baja latencia, reproducción de música e incluso audio espacial 3D, todo en el navegador.</p>
<p><strong>Pruébalo (haz clic en un botón para escuchar sonidos sintetizados):</strong></p>
<AudioDemo />
<h2>El problema de la reproducción automática</h2>
<p>Antes que nada, debes comprender las restricciones de reproducción automática. Los navegadores bloquean el audio hasta que el usuario interactúa con la página. Esto es algo positivo (nadie quiere que los sitios web reproduzcan sonidos a todo volumen al cargarse), pero implica que debes inicializar el audio en respuesta a un clic, un toque o la pulsación de una tecla.</p>
<pre><code class="language-js">let audioCtx = null

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

// Initialize on first interaction
document.addEventListener('click', () =&gt; initAudio(), { once: true })
document.addEventListener('keydown', () =&gt; initAudio(), { once: true })
</code></pre>
<p>Si intentas reproducir audio antes de esto, fallará sin mostrar ningún error o el AudioContext permanecerá suspendido. Esto toma por sorpresa a muchos desarrolladores.</p>
<h2>Cargar sonidos</h2>
<p>Los archivos de audio deben decodificarse antes de reproducirse. Obtén el archivo, decodifícalo y conserva el búfer para reutilizarlo:</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>La decodificación lleva tiempo, así que carga todos los sonidos durante una pantalla de carga, no cuando necesites reproducirlos.</p>
<h2>Reproducir sonidos</h2>
<p>Para reproducir un sonido, crea una fuente de búfer, conéctala mediante un nodo de ganancia para controlar el volumen e iníciala:</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>Las fuentes de búfer son de un solo uso. Una vez que una fuente termina de reproducirse, no puedes reiniciarla. Crea una nueva cada vez que reproduzcas el sonido. Puede parecer un desperdicio, pero así funciona la API y los navegadores están optimizados para ello.</p>
<h2>Un gestor de sonido</h2>
<p>Para un juego real, encapsula todo en una clase gestora:</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>El nodo de ganancia maestro te permite implementar un control deslizante de volumen global. Todos los sonidos pasan por él.</p>
<h2>Música de fondo</h2>
<p>La música debe poder reproducirse en bucle, detenerse y desvanecerse:</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>El método <code>linearRampToValueAtTime</code> permite crear desvanecimientos suaves en lugar de cortes bruscos.</p>
<h2>Audio espacial</h2>
<p>En juegos 3D, puedes colocar los sonidos en el espacio. Los sonidos pierden volumen a medida que se alejan del oyente:</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>Llama a <code>updateListener</code> en cada fotograma con la posición de la cámara o del jugador. El nodo de posicionamiento ajusta automáticamente el volumen y el balance estéreo según la distancia y la dirección.</p>
<h2>Sonidos rápidos y repetidos</h2>
<p>Para los sonidos que se reproducen muchas veces por segundo (disparos, pasos), no conviene que se acumulen instancias superpuestas. Un patrón sencillo de pool ayuda a evitarlo:</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>El pool limita cuántas instancias del mismo sonido pueden reproducirse a la vez. Al alcanzar el límite, la instancia más antigua se sustituye.</p>
<h2>Formatos de audio</h2>
<p>Cada formato funciona mejor en situaciones diferentes:</p>
<p>MP3 funciona en todas partes y es adecuado para la música. OGG Vorbis ofrece mejor calidad con el mismo tamaño de archivo. Safari fue el último navegador importante que no lo admitía, pero añadió reproducción nativa de Ogg Vorbis en Safari 18.4, por lo que ahora todos los navegadores principales actuales son compatibles. Aun así, conviene ofrecer MP3 como alternativa si te importan las versiones anteriores de Safari. AAC/M4A funciona bien en dispositivos Apple. WAV no está comprimido y ocupa mucho espacio, pero se decodifica al instante, lo cual resulta útil para efectos de sonido cortos cuando el tamaño del archivo no es un problema. WebM/Opus ofrece la mejor relación entre calidad y tamaño, pero solo funciona en navegadores modernos.</p>
<p>Para lograr la máxima compatibilidad, proporciona alternativas:</p>
<pre><code class="language-js">const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'
</code></pre>
<h2>Trucos útiles</h2>
<p>La <strong>variación de tono</strong> hace que los sonidos repetidos resulten menos robóticos:</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>Así, cada disparo o paso suena ligeramente diferente.</p>
<p>La <strong>atenuación</strong> reduce el volumen de la música durante los diálogos o los sonidos importantes:</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>Particularidades de iOS</h2>
<p>iOS impone restricciones de audio más estrictas que otras plataformas. El AudioContext debe crearse y reanudarse durante un gesto del usuario. Algunas versiones de iOS también requieren que reproduzcas un sonido (aunque sea silencioso) durante el gesto, no solo que crees el contexto. Si el audio funciona en equipos de escritorio, pero no en iOS, probablemente sea por este motivo.</p>
<h2>Más recursos</h2>
<p><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a> explica la compresión de recursos, incluido el audio.</p>
<p><a href="/es/tutorials/mobile-friendly-web-games">Juegos web adaptados a dispositivos móviles</a> ofrece más información sobre problemas específicos de iOS.</p>
<p><a href="/es/tutorials/pwa-offline-games">PWA para juegos sin conexión</a> muestra cómo almacenar en caché archivos de audio para jugar sin conexión.</p>
<p><a href="/es/tutorials/tone-js-game-audio">Tone.js para el audio de juegos</a> explica la generación procedural de sonidos y los sistemas musicales mediante una biblioteca de más alto nivel.</p>
<p><a href="/es/guides/game-assets-guide">Dónde encontrar recursos gratuitos para juegos</a> enumera fuentes gratuitas de efectos de sonido y música, como Freesound y Poly Haven.</p>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API">MDN: Web Audio API</a> — referencia completa de la API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioContext">MDN: AudioContext</a> — creación y gestión de contextos de audio</li>
<li><a href="https://www.w3.org/TR/webaudio/">Especificación de Web Audio API</a> — especificación del W3C</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web Workers para la lógica del juego]]></title>
            <link>https://app.cinevva.com/es/tutorials/web-workers-game-logic</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/web-workers-game-logic</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Delega los cálculos intensivos a Web Workers: física, búsqueda de rutas, IA y generación procedimental sin bloquear el hilo principal.]]></description>
            <content:encoded><![CDATA[<h1>Web Workers para la lógica del juego</h1>
<p>Los Web Workers permiten ejecutar JavaScript en un hilo en segundo plano. En los juegos, esto significa que la física, la IA y la generación procedimental pueden ejecutarse sin provocar caídas de fotogramas.</p>
<h2>1) Cuándo usar Workers</h2>
<p>Buenos casos de uso:</p>
<ul>
<li>Simulación física</li>
<li>Búsqueda de rutas (A*, navmesh)</li>
<li>Toma de decisiones de la IA</li>
<li>Generación procedimental</li>
<li>Procesamiento de recursos (manipulación de imágenes, compresión)</li>
<li>Matemáticas complejas (FFT, detección de colisiones)</li>
</ul>
<p>No son ideales para:</p>
<ul>
<li>Renderizado (los Workers no pueden acceder directamente al DOM ni a Canvas)</li>
<li>Tareas muy pequeñas (la sobrecarga del hilo no compensa)</li>
</ul>
<h2>2) Crear un Worker básico</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) {
  // Trabajo intensivo aquí
  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('Resultado obtenido:', data)
  }
}

worker.postMessage({ type: 'calculate', data: 42 })
</code></pre>
<h2>3) Workers en línea (sin archivo separado)</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 como módulos ES</h2>
<p>Puedes escribir el código del Worker como un módulo ES y usar <code>import</code> dentro de él, en lugar de <code>importScripts</code>. Pasa <code>{ type: 'module' }</code> al constructor:</p>
<pre><code class="language-js">const worker = new Worker('physics-worker.js', { type: 'module' })
</code></pre>
<p>Dentro del Worker puedes usar <code>import</code> para cargar utilidades compartidas de matemáticas, colisiones o IA del mismo modo que en el hilo principal, para no duplicar la lógica del juego. Los Workers como módulos son compatibles con Chrome y Edge 80+, Safari 15+ y Firefox 114+.</p>
<h2>5) Ejemplo de Worker de física</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) {
  // Inicializar el mundo físico
}

function step() {
  // Actualizar la física
  for (const body of bodies) {
    body.vy += 9.8 * FIXED_DT // Gravedad
    body.x += body.vx * FIXED_DT
    body.y += body.vy * FIXED_DT
  }
  
  // Devolver las posiciones
  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) Objetos transferibles para mejorar el rendimiento</h2>
<p>Los datos de gran tamaño pueden transferirse sin copiarlos:</p>
<pre><code class="language-js">// Hilo principal
const positions = new Float32Array(1000)
worker.postMessage(positions, [positions.buffer])
// positions ya no se puede usar aquí (se ha transferido)

// Worker
self.onmessage = (e) =&gt; {
  const positions = e.data
  // Trabajar con las posiciones
  self.postMessage(positions, [positions.buffer])
}
</code></pre>
<h2>7) Worker para búsqueda de rutas</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) {
  // Implementación de A*
  const openSet = [start]
  const cameFrom = new Map()
  const gScore = new Map()
  gScore.set(key(start), 0)
  
  while (openSet.length &gt; 0) {
    // ... Lógica de A*
  }
  
  return reconstructPath(cameFrom, end)
}

function key(pos) {
  return `${pos.x},${pos.y}`
}
</code></pre>
<h2>8) Grupo de Workers para tareas en paralelo</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 para sincronización en tiempo real</h2>
<p>Con el aislamiento entre orígenes activado, puedes compartir memoria:</p>
<pre><code class="language-js">// Hilo principal
const shared = new SharedArrayBuffer(1024)
const positions = new Float32Array(shared)

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

// El Worker lee y escribe directamente en la memoria compartida
// Sin sobrecarga de postMessage para actualizar posiciones
</code></pre>
<h2>10) OffscreenCanvas para Workers</h2>
<p>Renderiza en un Worker. OffscreenCanvas ya forma parte de Baseline y está ampliamente disponible en Chrome, Edge, Firefox y Safari (Safari añadió compatibilidad en la versión 17.0 para macOS e iOS):</p>
<pre><code class="language-js">// Hilo principal
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)
    // Dibujar...
    requestAnimationFrame(render)
  }
  render()
}
</code></pre>
<h2>11) Gestión de errores</h2>
<pre><code class="language-js">worker.onerror = (e) =&gt; {
  console.error('Error del Worker:', e.message, e.filename, e.lineno)
}

// En el Worker
self.onerror = (e) =&gt; {
  self.postMessage({ type: 'error', message: e.message })
}
</code></pre>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/coop-coep-sharedarraybuffer">Activa los hilos de Wasm (SharedArrayBuffer)</a></li>
<li><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a></li>
<li><a href="/es/tutorials/game-physics-libraries">Bibliotecas de física para juegos</a> — ejecutar Rapier o Cannon-es en un Worker</li>
<li><a href="/es/tutorials/streaming-asset-loading">Carga de recursos por streaming</a> — decodificar recursos fuera del hilo principal</li>
<li><a href="/es/guides/web-games-stack-2026">Stack tecnológico para juegos web en 2026</a> — cómo encajan los Workers en el stack de WebGL, WebGPU y Wasm</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API">MDN: API de Web Workers</a> — referencia completa de la API</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">MDN: Uso de Web Workers</a> — guía paso a paso</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects">MDN: Objetos transferibles</a> — transferencia de datos sin copias</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">MDN: SharedArrayBuffer</a> — memoria compartida entre hilos</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fundamentos de WebGL para desarrolladores de juegos]]></title>
            <link>https://app.cinevva.com/es/tutorials/webgl-fundamentals-for-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/webgl-fundamentals-for-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Aprende los fundamentos de WebGL para juegos: shaders, búferes, texturas y el bucle de renderizado. Código práctico que puedes copiar y ampliar.]]></description>
            <content:encoded><![CDATA[<h1>Fundamentos de WebGL para desarrolladores de juegos</h1>
<p>WebGL te da acceso directo a la GPU desde el navegador. Es la base de los juegos web 3D (y de los juegos 2D de alto rendimiento). Este tutorial cubre los conceptos esenciales.</p>
<h2>1) Obtener un contexto 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>Prefiere <code>webgl2</code> cuando esté disponible: es ampliamente compatible y ofrece mejores prestaciones.</p>
<h2>2) El pipeline de renderizado (simplificado)</h2>
<ol>
<li>El <strong>shader de vértices</strong> se ejecuta una vez por vértice. Posiciona la geometría.</li>
<li>El <strong>shader de fragmentos</strong> se ejecuta una vez por píxel. Colorea el resultado.</li>
<li>Los búferes almacenan datos (posiciones, coordenadas UV y colores).</li>
<li>Las texturas son imágenes que los shaders muestrean.</li>
</ol>
<h2>3) Un par mínimo de shaders</h2>
<p><strong>Shader de vértices:</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>Shader de fragmentos:</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) Compilar y enlazar 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) Crear búferes</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) Conectar los búferes con los 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) El bucle del juego</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) Cargar texturas</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, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
    gl.generateMipmap(gl.TEXTURE_2D)
  }
  img.src = url
  return texture
}
</code></pre>
<h2>9) Errores habituales</h2>
<ul>
<li>Las <strong>texturas con dimensiones que son potencias de dos</strong> no son estrictamente necesarias en WebGL 2, pero los mipmaps funcionan mejor con ellas.</li>
<li><strong>Pérdida del contexto</strong>: gestiona los eventos <code>webglcontextlost</code>; los navegadores móviles liberan contextos cuando hay presión sobre la memoria.</li>
<li>Las <strong>texturas de coma flotante</strong> requieren comprobar la extensión (<code>EXT_color_buffer_float</code>).</li>
</ul>
<h2>10) Cuándo usar una biblioteca</h2>
<p>WebGL puro requiere mucho código. Para la mayoría de los juegos, considera:</p>
<ul>
<li><strong>Three.js</strong> — 3D con todas las funciones</li>
<li><strong>PixiJS</strong> — renderizado 2D rápido</li>
<li><strong>PlayCanvas</strong> — motor de juegos con editor</li>
</ul>
<p>Usa WebGL puro cuando necesites:</p>
<ul>
<li>control máximo</li>
<li>un renderizador personalizado</li>
<li>aprender cómo funcionan realmente las cosas</li>
</ul>
<p>Una nota para 2026: WebGPU ya viene activado de forma predeterminada en todos los navegadores principales (Chrome y Edge desde la versión 113, Firefox 141 en Windows y 145 en macOS, y Safari en iOS 26, iPadOS 26 y macOS Tahoe 26). Los fundamentos explicados aquí siguen siendo aplicables, ya que bibliotecas como Three.js ejecutan su renderizador WebGPU con WebGL 2 como alternativa automática, por lo que WebGL 2 continúa siendo la base más fiable. Si hoy vas a crear un renderizador nuevo desde cero, lee el tutorial de WebGPU junto con este antes de comprometerte con una API.</p>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/webgpu-getting-started">WebGPU para juegos</a></li>
<li><a href="/es/tutorials/canvas-2d-game-loop">Bucle de juego con Canvas 2D</a></li>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/guides/web-games-stack-2026">Stack tecnológico para juegos web en 2026</a> — cuándo elegir WebGL, WebGPU o Wasm</li>
<li><a href="/es/tutorials/game-physics-libraries">Bibliotecas de física para juegos</a> — motores de física que renderizan mediante WebGL</li>
<li><a href="/es/guides/web-game-engines-comparison">Comparativa de motores de juegos web</a> — Three.js, PixiJS, PlayCanvas y otros</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API">MDN: API de WebGL</a> — referencia completa de la API</li>
<li><a href="https://webgl2fundamentals.org/">Fundamentos de WebGL2</a> — serie de tutoriales detallados sobre WebGL2</li>
<li><a href="https://thebookofshaders.com/">El libro de los shaders</a> — guía interactiva sobre shaders de fragmentos GLSL</li>
<li><a href="https://threejs.org/docs/">Documentación de Three.js</a> — el framework WebGL más popular</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Primeros pasos con WebGPU para desarrolladores de juegos]]></title>
            <link>https://app.cinevva.com/es/tutorials/webgpu-getting-started</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/webgpu-getting-started</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Empieza a usar WebGPU: configuración del dispositivo, pipelines, shaders y renderizado. Una API de GPU moderna para la próxima generación de juegos web.]]></description>
            <content:encoded><![CDATA[<h1>Primeros pasos con WebGPU para desarrolladores de juegos</h1>
<p>WebGPU es el sucesor de WebGL. Es de más bajo nivel, más explícito y está diseñado para las GPU modernas. Si vas a iniciar un nuevo proyecto 3D en 2026, vale la pena aprender WebGPU.</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">Tutorial de WebGPU: 10 proyectos que abarcan desde la configuración hasta el renderizado 3D con iluminación y animación</p>
<h2>1) Comprueba la compatibilidad del navegador</h2>
<pre><code class="language-js">if (!navigator.gpu) {
  console.error('WebGPU not supported')
}
</code></pre>
<p>En 2026, WebGPU está disponible en Chrome, Edge, Firefox y Safari.</p>
<h2>2) Obtén un dispositivo</h2>
<pre><code class="language-js">const adapter = await navigator.gpu.requestAdapter()
const device = await adapter.requestDevice()
</code></pre>
<p>El adaptador representa la GPU física. El dispositivo es tu interfaz para interactuar con ella.</p>
<p>Ambas llamadas pueden fallar. <code>requestAdapter()</code> devuelve <code>null</code> cuando el sistema no tiene una GPU utilizable y <code>requestDevice()</code> puede rechazar la solicitud, así que compruébalo antes de continuar:</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>Además, el adaptador queda consumido cuando creas un dispositivo a partir de él, así que solicita uno nuevo si necesitas otro dispositivo.</p>
<h2>3) Configura el canvas</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) Escribe shaders en WGSL</h2>
<p>WebGPU utiliza WGSL (WebGPU Shading Language), no 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) Crea un módulo de shaders</h2>
<pre><code class="language-js">const shaderModule = device.createShaderModule({
  code: wgslSource,
})
</code></pre>
<h2>6) Crea un pipeline de renderizado</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) Renderiza un fotograma</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) Búferes en 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) Grupos de enlace para uniforms</h2>
<pre><code class="language-js">const uniformBuffer = device.createBuffer({
  size: 64,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer },
  }],
})
</code></pre>
<h2>10) WebGPU frente a WebGL</h2>
<table>
<thead>
<tr>
<th>Aspecto</th>
<th>WebGL</th>
<th>WebGPU</th>
</tr>
</thead>
<tbody>
<tr>
<td>Estilo de API</td>
<td>Similar a OpenGL, con estado</td>
<td>Moderno, explícito</td>
</tr>
<tr>
<td>Shaders</td>
<td>GLSL</td>
<td>WGSL</td>
</tr>
<tr>
<td>Cómputo</td>
<td>Limitado (extensiones)</td>
<td>Compatibilidad de primer nivel</td>
</tr>
<tr>
<td>Multihilo</td>
<td>Difícil</td>
<td>Diseñado para ello</td>
</tr>
<tr>
<td>Compatibilidad con navegadores</td>
<td>Universal</td>
<td>Todos los navegadores principales (Chrome, Edge, Safari 26, Firefox)</td>
</tr>
</tbody>
</table>
<h2>Cuándo utilizar WebGPU</h2>
<ul>
<li>Proyectos nuevos dirigidos a navegadores modernos</li>
<li>Cargas de trabajo con uso intensivo de cómputo (física, IA y generación procedural)</li>
<li>Cuando necesitas un control preciso de los recursos de la GPU</li>
</ul>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/webgl-fundamentals-for-games">Fundamentos de WebGL</a></li>
<li><a href="/es/tutorials/web-workers-game-logic">Web Workers para la lógica del juego</a></li>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/guides/web-games-stack-2026">Stack tecnológico para juegos web en 2026</a> — marco de decisión para elegir entre WebGL, WebGPU y Wasm</li>
<li><a href="/es/tutorials/coop-coep-sharedarraybuffer">COOP/COEP y SharedArrayBuffer</a> — cabeceras necesarias para usar multihilo de Wasm con cómputo de WebGPU</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://www.w3.org/TR/webgpu/">Especificación de WebGPU</a> — especificación del W3C</li>
<li><a href="https://www.w3.org/TR/WGSL/">Especificación de WGSL</a> — especificación del lenguaje de shaders de WebGPU</li>
<li><a href="https://webgpufundamentals.org/">Fundamentos de WebGPU</a> — serie de tutoriales en profundidad</li>
<li><a href="https://codelabs.developers.google.com/your-first-webgpu-app">Tu primera aplicación WebGPU</a> — guía paso a paso de Google Codelab</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fundamentos del multijugador con WebSocket]]></title>
            <link>https://app.cinevva.com/es/tutorials/websocket-multiplayer-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/websocket-multiplayer-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Crea juegos multijugador en tiempo real con WebSockets: gestión de conexiones, protocolos de mensajes y patrones de sincronización para juegos web.]]></description>
            <content:encoded><![CDATA[<h1>Fundamentos del multijugador con 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">Cómo crear un juego multijugador con WebSockets: arquitectura del servidor, salas de juego y sincronización del estado</p>
<p>Los WebSockets permiten la comunicación en tiempo real en juegos multijugador. Este tutorial explica los fundamentos del lado del cliente.</p>
<h2>1) Conexión a un servidor</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) Diseño del protocolo de mensajes</h2>
<p>Define un protocolo sencillo:</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) Clase para gestionar la conexión</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) Envío de las entradas del jugador</h2>
<p>Envía las entradas, no las posiciones. Deja que el servidor sea la autoridad:</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) Recepción del estado del juego</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) Interpolación para un movimiento fluido</h2>
<p>El servidor envía instantáneas. Interpola entre ellas:</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) Predicción del lado del cliente</h2>
<p>Para que los controles respondan de inmediato, predice localmente y concilia el resultado con el servidor:</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) Visualización de la latencia</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) Mensajes binarios para mejorar la eficiencia</h2>
<p>Para actualizaciones de alta frecuencia, usa datos binarios:</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) Ejemplo básico de servidor (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>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/ship-web-game-fast">Publica un juego web que cargue rápido</a></li>
<li><a href="/es/tutorials/game-input-handling">Gestión de entradas en juegos</a></li>
<li><a href="/es/tutorials/web-workers-game-logic">Web Workers para la lógica del juego</a></li>
<li><a href="/es/tutorials/coop-coep-sharedarraybuffer">COOP/COEP y SharedArrayBuffer</a> — encabezados necesarios para usar SharedArrayBuffer en contextos multijugador</li>
<li><a href="/es/guides/co-op-game-design">Diseño de juegos cooperativos en 2026</a> — por qué los juegos cooperativos «friend slop» están triunfando y cómo encaja el multijugador en el navegador</li>
<li><a href="/es/guides/game-jams-hackathons">Game jams y hackatones</a> — los juegos multijugador de game jams reciben más atención</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API">MDN: API WebSocket</a> — referencia de WebSocket para navegadores</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API">MDN: API WebRTC</a> — conexiones entre pares para reducir la latencia</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API">MDN: API WebTransport</a> — un transporte HTTP/3 más reciente que alcanzó la compatibilidad básica entre navegadores en marzo de 2026, cuando Safari 26.4 incorporó su implementación (ya estaba disponible en Chrome 97+, Edge y Firefox 114+). A diferencia de un único WebSocket basado en TCP, WebTransport funciona sobre QUIC y ofrece datagramas no fiables, además de varios flujos independientes, por lo que un paquete perdido no bloquea todo lo que viene detrás. Un patrón habitual consiste en enviar actualizaciones de posición de alta frecuencia mediante datagramas y mantener el chat o las tablas de clasificación en un flujo fiable. WebSockets sigue siendo la opción con mayor compatibilidad y todo lo anterior continúa siendo válido, pero ahora vale la pena considerar WebTransport para juegos sensibles a la latencia.</li>
<li><a href="https://socket.io/docs/v4/">Documentación de Socket.IO</a> — popular biblioteca contenedora para WebSocket</li>
<li><a href="https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking">Red multijugador de Valve Source</a> — patrones de red autoritativa utilizados en juegos en producción</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fundamentos de WebXR para juegos de RV]]></title>
            <link>https://app.cinevva.com/es/tutorials/webxr-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/es/tutorials/webxr-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Primeros pasos con WebXR: configuración de sesiones, renderizado, controladores e interacciones. Crea experiencias de RV que se ejecuten en el navegador.]]></description>
            <content:encoded><![CDATA[<h1>Fundamentos de WebXR para juegos de RV</h1>
<p>WebXR lleva la RV y la RA al navegador. Los jugadores pueden disfrutar de tu juego con un visor sin descargar nada.</p>
<h2>1) Comprobar la compatibilidad con 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 }
}

// Uso
const support = await checkXRSupport()
if (support.vr) {
  showVRButton()
}
</code></pre>
<h2>2) Iniciar una sesión de RV</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)
    
    // Configurar el renderizado
    const gl = canvas.getContext('webgl2', { xrCompatible: true })
    await xrSession.updateRenderState({
      baseLayer: new XRWebGLLayer(xrSession, gl),
    })
    
    // Obtener el espacio de referencia
    xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
    
    // Iniciar el bucle de renderizado
    xrSession.requestAnimationFrame(onXRFrame)
  } catch (err) {
    console.error('Failed to start VR:', err)
  }
}

function onSessionEnd() {
  xrSession = null
  xrRefSpace = null
}
</code></pre>
<h2>3) El bucle de renderizado de 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)
  
  // Renderizar para cada ojo
  for (const view of pose.views) {
    const viewport = glLayer.getViewport(view)
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
    
    // Obtener las matrices de la cámara
    const viewMatrix = view.transform.inverse.matrix
    const projectionMatrix = view.projectionMatrix
    
    // Renderizar la escena con estas matrices
    renderScene(viewMatrix, projectionMatrix)
  }
}
</code></pre>
<h2>4) Fuentes de entrada (controladores)</h2>
<pre><code class="language-js">xrSession.addEventListener('inputsourceschange', (e) =&gt; {
  for (const source of e.added) {
    console.log('Controller added:', source.handedness) // 'left' o '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
    
    // Gatillo (normalmente, índice 0)
    const triggerPressed = gamepad.buttons[0]?.pressed
    const triggerValue = gamepad.buttons[0]?.value || 0
    
    // Agarre (normalmente, índice 1)
    const gripPressed = gamepad.buttons[1]?.pressed
    
    // Joystick
    const thumbstickX = gamepad.axes[2] || 0
    const thumbstickY = gamepad.axes[3] || 0
    
    // Botones A/B (índices 4 y 5)
    const buttonA = gamepad.buttons[4]?.pressed
    const buttonB = gamepad.buttons[5]?.pressed
  }
}
</code></pre>
<h3>No des por sentado que todos los dispositivos tienen un gamepad</h3>
<p>El código anterior lee <code>source.gamepad</code> para los gatillos y joysticks. Esto funciona en visores con controladores como Meta Quest, pero no hace nada en Apple Vision Pro (WebXR está activado de forma predeterminada en Safari de visionOS 2), Samsung Galaxy XR y otros dispositivos Android XR, donde la entrada principal se basa en la mirada y el gesto de pellizco o en el seguimiento de manos. En esos visores, <code>source.gamepad</code> es null y <code>session.inputSources</code> permanece vacío hasta que el usuario hace realmente el gesto de pellizco.</p>
<p>Para la única interacción que garantizan todos los dispositivos, la «acción principal», escucha los eventos <code>select</code> en lugar de consultar continuamente los botones. WebXR emite <code>selectstart</code>, <code>select</code> y <code>selectend</code> de la misma forma para el gatillo de un controlador, un pellizco con la mano y un gesto de mirada y pellizco de Vision Pro, por lo que un único controlador de eventos sirve para todos ellos:</p>
<pre><code class="language-js">xrSession.addEventListener('selectstart', (e) =&gt; {
  // e.inputSource es el controlador, la mano o el pellizco transitorio
  // e.frame permite leer la pose de su targetRaySpace
})
xrSession.addEventListener('select', (e) =&gt; {
  // acción principal completada (gatillo presionado, pellizco soltado sobre el objetivo)
})
xrSession.addEventListener('selectend', (e) =&gt; {})
</code></pre>
<p>Conserva el código del gamepad para el desplazamiento con joystick y los botones adicionales en visores con controladores, pero protégelo con <code>if (source.gamepad)</code> y canaliza la interacción principal de «seleccionar/agarrar/activar» a través de estos eventos para que el juego siga funcionando en dispositivos controlados mediante las manos y la mirada.</p>
<h2>5) Posiciones de los controladores</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) Lanzamiento de rayos para apuntar</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) Desplazamiento por teletransporte</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) {
        // Apuntar
        this.isAiming = true
        const ray = getControllerRay(frame, source)
        this.targetPosition = this.findTeleportTarget(ray)
      } else if (this.isAiming) {
        // Soltar: teletransportarse
        if (this.targetPosition) {
          player.position.x = this.targetPosition.x
          player.position.z = this.targetPosition.z
        }
        this.isAiming = false
        this.targetPosition = null
      }
    }
  }
  
  findTeleportTarget(ray) {
    // Intersecar con el plano del suelo
    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) Desplazamiento fluido</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
    
    // Aplicar la zona muerta
    if (Math.abs(moveX) &lt; 0.1) moveX = 0
    if (Math.abs(moveY) &lt; 0.1) moveY = 0
    
    // Obtener la dirección de la cabeza para el movimiento
    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] }
    
    // Normalizar al plano 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) Pautas de comodidad para RV</h2>
<pre><code class="language-js">// Viñeta durante el movimiento para reducir el mareo por movimiento
function renderComfortVignette(movementSpeed) {
  const intensity = Math.min(movementSpeed / 5, 0.5)
  if (intensity &lt; 0.1) return
  
  // Renderizar bordes oscuros que aumentan con la velocidad
  renderVignette(intensity)
}

// Giro por intervalos
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 grados
    player.rotation += angle
    snapTurnCooldown = 0.3 // Evitar giros rápidos
  }
}
</code></pre>
<h2>10) Configuración completa de WebXR</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
    
    // Actualizar la lógica del juego
    this.teleport.update(frame, this.session.inputSources)
    
    // Renderizar
    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) {
    // Tu código de renderizado va aquí
  }
  
  onEnd() {
    this.session = null
  }
}
</code></pre>
<h2>Contenido relacionado</h2>
<ul>
<li><a href="/es/tutorials/webgl-fundamentals-for-games">Fundamentos de WebGL</a></li>
<li><a href="/es/tutorials/webgpu-getting-started">Primeros pasos con WebGPU</a></li>
<li><a href="/es/tutorials/game-input-handling">Gestión de entradas en juegos</a></li>
<li><a href="/es/guides/web-games-stack-2026">Tecnologías para juegos web en 2026</a> — dónde encaja WebXR en el panorama tecnológico general</li>
<li><a href="/es/guides/threejs-usdc-tech-report">Three.js + USDC en el navegador</a> — carga de recursos 3D para escenas XR</li>
</ul>
<h2>Recursos externos</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API">MDN: API de dispositivos WebXR</a> — referencia completa de la API</li>
<li><a href="https://immersiveweb.dev/">Immersive Web</a> — ejemplos, herramientas y tablas de compatibilidad con navegadores para WebXR</li>
<li><a href="https://threejs.org/docs/#manual/en/introduction/How-to-create-VR-content">Guía de WebXR de Three.js</a> — contenido de RV con Three.js</li>
</ul>
]]></content:encoded>
        </item>
    </channel>
</rss>
