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:
- Críticos — Necesarios para mostrar la primera pantalla (< 1 MB)
- Jugabilidad — Necesarios para jugar (< 10 MB)
- Mejoras — Opcionales (se cargan en segundo plano)
2) Cargador de recursos básico
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
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
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
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és6) Carga diferida de niveles
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:
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 llegan8) Almacenamiento en caché de los recursos cargados
Combínalo con IndexedDB para disponer de una caché persistente:
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
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
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
- Publica un juego web que cargue rápido
- Service workers para almacenar juegos en caché
- Partidas guardadas en IndexedDB
- Web Workers para la lógica del juego — decodifica y procesa recursos fuera del hilo principal
- Dónde encontrar recursos gratuitos para juegos — fuentes de modelos 3D, texturas y audio para transmitir
Recursos externos
- MDN: API Fetch — transmisión de respuestas y ReadableStream
- MDN: ReadableStream — procesamiento de fragmentos de datos transmitidos
- Especificación de glTF 2.0 — el formato estándar para transmitir recursos 3D