Skip to content

Carga progresiva de recursos para juegos web ​

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.

1) La estrategia de carga ​

Divide los recursos en niveles:

  1. Críticos — Necesarios para mostrar la primera pantalla (< 1 MB)
  2. Jugabilidad — Necesarios para jugar (< 10 MB)
  3. Mejoras — Opcionales (se cargan en segundo plano)

2) Cargador de recursos básico ​

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) => {
      const img = new Image()
      img.onload = () => {
        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
  }
}

3) Carga con indicador de progreso ​

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
}

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), Content-Length representa el tamaño comprimido, mientras que los fragmentos que lees ya están descomprimidos. Esto hace que received / total 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 onprogress de XMLHttpRequest, que informa del progreso real de la transferencia.

4) Carga por lotes con progreso general ​

js
async function loadAssets(manifest, onProgress) {
  const total = manifest.length
  let completed = 0
  const results = {}
  
  const promises = manifest.map(async (item) => {
    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) => {
  console.log(`Cargando: ${Math.round(progress * 100)}% (${name})`)
})

5) Cargador con cola de prioridad ​

js
class PriorityLoader {
  constructor(concurrency = 4) {
    this.queue = []
    this.active = 0
    this.concurrency = concurrency
  }
  
  add(url, priority = 0) {
    return new Promise((resolve, reject) => {
      this.queue.push({ url, priority, resolve, reject })
      this.queue.sort((a, b) => b.priority - a.priority)
      this.process()
    })
  }
  
  async process() {
    if (this.active >= 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

6) Carga diferida de niveles ​

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
  }
}

7) Transmisión de archivos grandes ​

Para archivos grandes (modelos 3D, audio), transmítelos y procésalos de forma incremental:

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

8) Almacenamiento en caché de los recursos cargados ​

Combínalo con IndexedDB para disponer de una caché persistente:

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) => {
      const img = new Image()
      img.onload = () => {
        URL.revokeObjectURL(img.src)
        resolve(img)
      }
      img.src = URL.createObjectURL(blob)
    })
  }
  
  // Métodos de IndexedDB...
}

9) Patrón de pantalla de carga ​

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)
  }
}

10) Buenas prácticas ​

  • Muestra algo de inmediato — aunque solo sea una imagen estática
  • Carga primero lo que está visible — las texturas de la vista actual
  • Usa marcadores de posición — imágenes de baja resolución que se sustituyan por otras de mayor calidad
  • Precarga el siguiente nivel — mientras el jugador aún está jugando
  • Gestiona los errores correctamente — lógica de reintentos y alternativas
js
async function loadWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fetch(url)
    } catch (err) {
      if (i === maxRetries - 1) throw err
      await new Promise(r => setTimeout(r, 1000 * (i + 1)))
    }
  }
}

Contenido relacionado ​

Recursos externos ​