Skip to content

Pemuatan aset streaming untuk game web

Pemain tidak akan menunggu unduhan sebesar 100 MB. Muat aset secara bertahap agar mereka dapat bermain sementara sisanya dimuat di latar belakang.

1) Strategi pemuatan

Bagi aset menjadi beberapa tingkat:

  1. Kritis — Diperlukan untuk menampilkan layar pertama (< 1 MB)
  2. Gameplay — Diperlukan untuk bermain (< 10 MB)
  3. Penyempurnaan — Sebaiknya tersedia (muat di latar belakang)

2) Pemuat aset dasar

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) Pemuatan dengan progres

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
}

Perlu diketahui: ketika server mengompresi respons (gzip atau brotli, seperti yang dilakukan sebagian besar CDN secara default), Content-Length adalah ukuran terkompresi, sedangkan potongan data yang Anda baca sudah didekompresi. Akibatnya, received / total dapat melampaui 100%. Untuk bilah progres tingkat byte yang akurat pada respons terkompresi, sajikan aset tanpa kompresi, kirim header khusus berisi ukuran tanpa kompresi, atau gunakan peristiwa onprogress milik XMLHttpRequest, yang melaporkan progres transfer sebenarnya.

4) Pemuatan batch dengan progres keseluruhan

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
}

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

const assets = await loadAssets(manifest, (progress, name) => {
  console.log(`Loading: ${Math.round(progress * 100)}% (${name})`)
})

5) Pemuat dengan antrean prioritas

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

// Usage
const loader = new PriorityLoader()
loader.add('critical.png', 10)  // Load first
loader.add('optional.png', 1)   // Load later

6) Pemuatan malas untuk level

js
class LevelManager {
  constructor(loader) {
    this.loader = loader
    this.levels = new Map()
  }
  
  async preload(levelId) {
    if (this.levels.has(levelId)) return
    
    const manifest = await this.loader.loadJSON(`levels/${levelId}/manifest.json`)
    const assets = await this.loadLevelAssets(manifest)
    this.levels.set(levelId, { manifest, assets })
  }
  
  async loadLevelAssets(manifest) {
    // Load only what's needed for this level
    const assets = {}
    
    for (const texture of manifest.textures) {
      assets[texture.name] = await this.loader.loadImage(texture.url)
    }
    
    return assets
  }
  
  unload(levelId) {
    this.levels.delete(levelId)
    // Assets can be garbage collected
  }
}

7) Streaming file besar

Untuk file besar (model 3D, audio), lakukan streaming dan pemrosesan secara bertahap:

js
async function streamLargeFile(url, onChunk) {
  const response = await fetch(url)
  const reader = response.body.getReader()
  
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    onChunk(value)
  }
}

// For audio: use Media Source Extensions
// For 3D: process mesh data as it arrives

8) Menyimpan aset yang telah dimuat dalam cache

Gabungkan dengan IndexedDB untuk caching persisten:

js
class CachedLoader {
  constructor() {
    this.memCache = new Map()
    this.dbName = 'AssetCache'
  }
  
  async loadImage(url) {
    // Check memory
    if (this.memCache.has(url)) return this.memCache.get(url)
    
    // Check IndexedDB
    const cached = await this.getFromDB(url)
    if (cached) {
      const img = await this.blobToImage(cached)
      this.memCache.set(url, img)
      return img
    }
    
    // Fetch and cache
    const response = await fetch(url)
    const blob = await response.blob()
    await this.saveToDB(url, blob)
    
    const img = await this.blobToImage(blob)
    this.memCache.set(url, img)
    return img
  }
  
  blobToImage(blob) {
    return new Promise((resolve) => {
      const img = new Image()
      img.onload = () => {
        URL.revokeObjectURL(img.src)
        resolve(img)
      }
      img.src = URL.createObjectURL(blob)
    })
  }
  
  // IndexedDB methods...
}

9) Pola layar pemuatan

js
class LoadingScreen {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.progress = 0
    this.message = 'Memuat...'
  }
  
  update(progress, message) {
    this.progress = progress
    this.message = message || this.message
    this.render()
  }
  
  render() {
    const { ctx, canvas } = this
    ctx.fillStyle = '#1a1a2e'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    
    // Progress bar
    const barWidth = canvas.width * 0.6
    const barHeight = 20
    const x = (canvas.width - barWidth) / 2
    const y = canvas.height / 2
    
    ctx.fillStyle = '#333'
    ctx.fillRect(x, y, barWidth, barHeight)
    
    ctx.fillStyle = '#4ade80'
    ctx.fillRect(x, y, barWidth * this.progress, barHeight)
    
    // Text
    ctx.fillStyle = '#fff'
    ctx.font = '16px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText(this.message, canvas.width / 2, y - 20)
    ctx.fillText(`${Math.round(this.progress * 100)}%`, canvas.width / 2, y + 50)
  }
}

10) Praktik terbaik

  • Segera tampilkan sesuatu — bahkan hanya gambar statis
  • Muat yang terlihat terlebih dahulu — tekstur untuk tampilan saat ini
  • Gunakan placeholder — gambar beresolusi rendah yang kemudian ditingkatkan
  • Pramuat level berikutnya — saat pemain masih bermain
  • Tangani kegagalan dengan baik — logika percobaan ulang dan fallback
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)))
    }
  }
}

Terkait

Sumber Daya Eksternal