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:
- Kritis — Diperlukan untuk menampilkan layar pertama (< 1 MB)
- Gameplay — Diperlukan untuk bermain (< 10 MB)
- Penyempurnaan — Sebaiknya tersedia (muat di latar belakang)
2) Pemuat aset dasar
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
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
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
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 later6) Pemuatan malas untuk level
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:
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 arrives8) Menyimpan aset yang telah dimuat dalam cache
Gabungkan dengan IndexedDB untuk caching persisten:
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
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
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
- Rilis game web yang dimuat dengan cepat
- Service worker untuk caching game
- Penyimpanan progres game dengan IndexedDB
- Web Worker untuk logika game — dekode dan proses aset di luar thread utama
- Tempat menemukan aset game gratis — sumber model 3D, tekstur, dan audio untuk streaming
Sumber Daya Eksternal
- MDN: Fetch API — streaming respons dan ReadableStream
- MDN: ReadableStream — memproses potongan data yang di-stream
- Spesifikasi glTF 2.0 — format standar untuk streaming aset 3D