<?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 Tutorial]]></title>
        <link>https://app.cinevva.com</link>
        <description><![CDATA[Tutorial praktis untuk pembuat game web.]]></description>
        <lastBuildDate>Sun, 16 Aug 2026 04:34:05 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[Alat kode AI agentik]]></title>
            <link>https://app.cinevva.com/id/tutorials/agentic-code-tools</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/agentic-code-tools</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Kuasai Claude Code, Cursor, dan GitHub Copilot: pahami alur kerja agentik, praktik terbaik, dan kapan menggunakan tiap alat untuk produktivitas maksimal.]]></description>
            <content:encoded><![CDATA[<h1>Alat kode AI agentik</h1>
<p>Asisten pemrograman AI telah berkembang dari pelengkapan otomatis sederhana menjadi agen otonom yang dapat menyusun rencana, mengedit banyak file, menjalankan pengujian, dan membuat pull request. Tutorial ini membahas tiga alat utama: Claude Code, Cursor, dan GitHub Copilot.</p>
<h2>1) Apa yang membuat sebuah alat disebut &quot;agentik&quot;?</h2>
<p>Pelengkapan kode AI tradisional menyarankan beberapa baris berikutnya. Alat agentik melangkah lebih jauh:</p>
<table>
<thead>
<tr>
<th>Kemampuan</th>
<th>Pelengkapan otomatis</th>
<th>Agentik</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cakupan</td>
<td>Baris/fungsi saat ini</td>
<td>Seluruh basis kode</td>
</tr>
<tr>
<td>Perencanaan</td>
<td>Tidak ada</td>
<td>Penalaran multilangkah</td>
</tr>
<tr>
<td>Eksekusi</td>
<td>Hanya saran</td>
<td>Dapat mengedit file dan menjalankan perintah</td>
</tr>
<tr>
<td>Konteks</td>
<td>File saat ini</td>
<td>Pemahaman penuh atas repositori</td>
</tr>
<tr>
<td>Alur kerja</td>
<td>Reaktif</td>
<td>Penyelesaian tugas secara otonom</td>
</tr>
</tbody>
</table>
<p>Alat agentik dapat memahami seluruh struktur proyek Anda, merencanakan perubahan di banyak file, menjalankan perubahan tersebut, melakukan pengujian, dan mengulang proses hingga tugas selesai.</p>
<h2>2) Perbandingan singkat: Claude Code vs Cursor vs Copilot</h2>
<table>
<thead>
<tr>
<th>Fitur</th>
<th>Claude Code</th>
<th>Cursor</th>
<th>GitHub Copilot</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Antarmuka</strong></td>
<td>CLI, web, plugin IDE</td>
<td>IDE lengkap (fork VS Code)</td>
<td>Ekstensi IDE</td>
</tr>
<tr>
<td><strong>Jendela konteks</strong></td>
<td>Besar (Opus berkonteks panjang)</td>
<td>Pengindeksan seluruh repositori</td>
<td>Lebih kecil, terus membaik</td>
</tr>
<tr>
<td><strong>Pengeditan banyak file</strong></td>
<td>Kuat</td>
<td>Sangat baik</td>
<td>Terus membaik</td>
</tr>
<tr>
<td><strong>Mode rencana</strong></td>
<td>Ya</td>
<td>Ya</td>
<td>Ya (lebih baru)</td>
</tr>
<tr>
<td><strong>Harga</strong></td>
<td>Tingkat Pro/Max</td>
<td>Kredit + langganan</td>
<td>Langganan</td>
</tr>
<tr>
<td><strong>Paling cocok untuk</strong></td>
<td>Arsitektur, refaktor besar</td>
<td>Pengembangan interaktif</td>
<td>Pemrograman inline</td>
</tr>
</tbody>
</table>
<h2>3) Dasar-dasar Claude Code</h2>
<p>Claude Code berjalan dengan Opus 4.8 (model default-nya saat ini) dan jendela konteks besar. Instal dan lakukan autentikasi:</p>
<pre><code class="language-bash"># Instal Claude Code (penginstal native, direkomendasikan)
curl -fsSL https://claude.ai/install.sh | bash

# Atau melalui npm (memerlukan Node 18+)
npm install -g @anthropic-ai/claude-code

# Autentikasi: jalankan claude dan ikuti perintah di browser
claude

# Mulai di proyek Anda
cd your-project
claude
</code></pre>
<h3>Perintah dasar</h3>
<pre><code class="language-bash"># Tanyakan tentang basis kode Anda
claude &quot;How is authentication implemented here?&quot;

# Hasilkan kode
claude &quot;Add a rate limiter middleware&quot;

# Refaktor banyak file
claude &quot;Rename UserService to AuthService across the codebase&quot;

# Buat PR
claude &quot;Create a PR that adds input validation to all API endpoints&quot;
</code></pre>
<h3>Konfigurasi proyek</h3>
<p>Tempatkan konteks proyek dalam file <code>CLAUDE.md</code> di root repositori Anda. Claude Code membacanya secara otomatis pada awal setiap sesi:</p>
<pre><code class="language-markdown"># Catatan proyek

- Bahasa: TypeScript, diformat dengan Prettier
- Pengujian: Vitest, di tests/
- Jangan ubah node_modules/ atau dist/
</code></pre>
<p>Pengaturan yang dapat dibaca mesin (izin alat, variabel lingkungan, hook) ditempatkan di <code>.claude/settings.json</code>. Misalnya, untuk mewajibkan persetujuan sebelum alat destruktif dijalankan, konfigurasikan izin di sana.</p>
<h3>Memori dan konteks</h3>
<p>Konteks proyek persisten disimpan dalam file <a href="http://CLAUDE.md">CLAUDE.md</a> yang dimuat Claude Code pada awal setiap sesi, ditambah penyimpanan memori otomatis yang diperbarui Claude berdasarkan koreksi dan preferensi Anda. Untuk memberinya konteks permanen, edit <a href="http://CLAUDE.md">CLAUDE.md</a> alih-alih memberikan prompt &quot;Remember:&quot; sekali pakai:</p>
<pre><code class="language-markdown"># CLAUDE.md
Ini adalah monorepo. Kode bersama berada di packages/.
</code></pre>
<p>Gunakan perintah <code>/clear</code> di dalam sesi untuk mereset konteks kerja.</p>
<h2>4) Fitur agentik IDE Cursor</h2>
<p>Cursor dibangun di seputar alur kerja agentik. Fitur utamanya:</p>
<h3>Mode Agent</h3>
<p>Tekan <code>Cmd+Shift+I</code> (Mac) atau <code>Ctrl+Shift+I</code> (Windows/Linux) untuk membuka Composer dalam mode Agent:</p>
<pre><code>Alur kerja mode Agent:
1. Jelaskan tugas Anda
2. Agen membuat rencana
3. Anda menyetujui atau mengubahnya
4. Agen menjalankan perubahan
5. Tinjau dan ulangi
</code></pre>
<h3>Mode Plan</h3>
<p>Untuk tugas kompleks, gunakan mode Plan secara eksplisit:</p>
<pre><code>@plan Tambahkan autentikasi pengguna dengan:
- Token JWT
- Rotasi refresh token
- Hashing kata sandi dengan bcrypt
- Pembatasan laju pada upaya login
</code></pre>
<p>Agen akan:</p>
<ol>
<li>Mempelajari kode Anda yang sudah ada</li>
<li>Membuat rencana implementasi terperinci</li>
<li>Menunggu persetujuan Anda</li>
<li>Menjalankannya langkah demi langkah</li>
</ol>
<h3>Penyisipan konteks</h3>
<p>Gunakan <code>@</code> untuk menambahkan konteks tertentu:</p>
<pre><code>@file src/api/auth.ts
@folder src/middleware
@docs https://jwt.io/introduction

Implementasikan middleware verifikasi JWT dengan mengikuti pola-pola ini
</code></pre>
<h3>Agen paralel</h3>
<p>Jalankan beberapa agen secara bersamaan di cabang yang berbeda:</p>
<pre><code># Di Cursor:
1. Buka Command Palette
2. &quot;Cursor: New Agent in Worktree&quot;
3. Setiap agen bekerja secara terisolasi
4. Bandingkan hasil secara berdampingan
</code></pre>
<p>Cursor 3 menambahkan Agents Window (buka dengan Cmd+Shift+P lalu &quot;Agents Window&quot;) yang menjalankan beberapa agen secara paralel di checkout lokal, git worktree, dan lingkungan cloud. Perintah /worktree menempatkan setiap agen dalam git worktree terisolasinya sendiri, sedangkan /best-of-n menjalankan tugas yang sama pada beberapa model agar Anda dapat membandingkan hasil secara berdampingan.</p>
<h3>Aturan untuk konsistensi</h3>
<p>Buat <code>.cursor/rules/project.mdc</code>:</p>
<pre><code class="language-markdown"># Aturan Proyek

## Gaya Kode
- Gunakan mode strict TypeScript
- Utamakan komponen fungsional
- Gunakan named export, bukan default export

## Pengujian
- Setiap fitur memerlukan pengujian
- Gunakan blok describe/it
- Buat mock untuk layanan eksternal

## Keamanan
- Jangan pernah commit rahasia
- Validasi semua input pengguna
- Gunakan kueri berparameter
</code></pre>
<h2>5) Fitur agentik GitHub Copilot</h2>
<p>Copilot telah berkembang melampaui pelengkapan otomatis:</p>
<h3>Copilot Chat</h3>
<p>Buka panel chat dan gunakan perintah slash:</p>
<pre><code>/explain - Jelaskan kode yang dipilih
/fix - Perbaiki masalah dalam pilihan
/tests - Hasilkan pengujian
/doc - Tambahkan dokumentasi
</code></pre>
<h3>Agen pemrograman (dari issue menjadi pull request)</h3>
<pre><code># Di GitHub:
1. Buka sebuah issue GitHub
2. Tetapkan &quot;Copilot&quot; sebagai penerima tugas
3. Agen pemrograman bekerja di latar belakang:
   menganalisis issue, mengedit file, menjalankan pengujian
4. Agen membuka pull request untuk Anda tinjau
</code></pre>
<p>Ini berbeda dari mode agent Copilot di dalam IDE, yang bekerja secara sinkron bersama Anda di VS Code atau JetBrains.</p>
<p>Copilot kini membagi pekerjaan otonomnya menjadi dua: mode agent berjalan secara sinkron di dalam IDE Anda (VS Code dan JetBrains), menentukan file yang perlu diedit, menjalankan perintah terminal, dan mengulangi proses saat terjadi kesalahan; agen pemrograman berjalan secara asinkron di cloud, menangani issue yang ditugaskan dan menghasilkan pull request sementara Anda mengerjakan hal lain.</p>
<h3>Saran inline dengan konteks</h3>
<p>Copilot membaca basis kode Anda. Bantu Copilot dengan komentar:</p>
<pre><code class="language-typescript">// Middleware autentikasi yang:
// - Mengambil JWT dari header Authorization
// - Memvalidasi tanda tangan token
// - Menambahkan pengguna ke request
// - Mengembalikan 401 jika tidak valid
export function authMiddleware(req, res, next) {
  // Copilot menghasilkan implementasinya
}
</code></pre>
<h2>6) Praktik terbaik untuk semua alat</h2>
<h3>Tulis prompt yang jelas</h3>
<pre><code>❌ Buruk: &quot;Perbaiki bug&quot;

✅ Baik: &quot;Perbaiki null pointer exception di UserService.getById
saat pengguna tidak ditemukan. Kembalikan null alih-alih melempar error.&quot;

❌ Buruk: &quot;Tambahkan pengujian&quot;

✅ Baik: &quot;Tambahkan unit test untuk CartService yang mencakup:
- Menambahkan item ke keranjang kosong
- Menghapus item
- Menghitung total dengan diskon
- Kasus khusus: jumlah negatif&quot;
</code></pre>
<h3>Gunakan mode rencana untuk tugas kompleks</h3>
<pre><code>Untuk setiap tugas yang menyentuh 3+ file, selalu:

1. Minta rencana terlebih dahulu
2. Tinjau rencana
3. Setujui atau ubah
4. Biarkan agen menjalankannya
5. Tinjau perubahan sebelum melakukan commit
</code></pre>
<h3>Berikan konteks arsitektur</h3>
<pre><code>Sebelum pekerjaan besar, beri agen penjelasan singkat:

&quot;Ini adalah arsitektur microservices:
- api-gateway: Merutekan request
- auth-service: JWT, sesi
- user-service: CRUD pengguna
- notification-service: Email, push

Layanan berkomunikasi melalui Redis pub/sub.
Basis datanya adalah PostgreSQL dengan Prisma ORM.&quot;
</code></pre>
<h3>Test-driven development dengan agen</h3>
<pre><code>1. Tulis pengujian yang gagal terlebih dahulu:
   &quot;Tulis pengujian untuk pendaftaran pengguna yang mengharapkan validasi email&quot;

2. Biarkan agen melihat pengujian yang gagal:
   &quot;Implementasikan pendaftaran agar pengujian ini lulus&quot;

3. Ulangi hingga semuanya berhasil:
   &quot;Perbaiki kegagalan pengujian yang tersisa&quot;
</code></pre>
<h2>7) Pertimbangan keamanan</h2>
<p>Alat agentik memiliki kekuatan nyata. Tetapkan batasan:</p>
<h3>Gerbang persetujuan</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>Sandboxing</h3>
<pre><code class="language-bash"># Jalankan agen di lingkungan terisolasi
docker run -v $(pwd):/workspace -it claude-sandbox

# Atau gunakan git worktree untuk isolasi
git worktree add ../feature-branch feature-branch
</code></pre>
<h3>File sensitif</h3>
<pre><code># .cursorignore / .claudeignore
.env
.env.*
secrets/
credentials/
*.pem
*.key
</code></pre>
<h3>Tinjau semua perubahan</h3>
<pre><code class="language-bash"># Sebelum menerima perubahan agen:
git diff --stat
git diff

# Gunakan staging interaktif
git add -p

# Jangan pernah melakukan auto-merge tanpa peninjauan
</code></pre>
<h2>8) Pola alur kerja yang efektif</h2>
<h3>Pola 1: Jelajahi, Rencanakan, Jalankan</h3>
<pre><code>Sesi 1: &quot;Bagaimana cara kerja sistem pembayaran?&quot;
        → Agen menjelajah, Anda mempelajarinya

Sesi 2: &quot;Apa yang diperlukan untuk menambahkan Stripe?&quot;
        → Agen membuat rencana

Sesi 3: &quot;Implementasikan integrasi Stripe&quot;
        → Agen menjalankan rencana
</code></pre>
<h3>Pola 2: Eksplorasi paralel</h3>
<pre><code># Di Cursor dengan agen paralel:

Agen 1: &quot;Implementasikan caching dengan Redis&quot;
Agen 2: &quot;Implementasikan caching dengan Memcached&quot;
Agen 3: &quot;Implementasikan caching dengan LRU dalam memori&quot;

→ Bandingkan implementasi, pilih pendekatan terbaik
</code></pre>
<h3>Pola 3: Migrasi bertahap</h3>
<pre><code>Langkah 1: &quot;Identifikasi semua penggunaan API yang sudah usang&quot;
Langkah 2: &quot;Buat adapter untuk API baru&quot;
Langkah 3: &quot;Migrasikan modul A agar menggunakan adapter&quot;
Langkah 4: &quot;Migrasikan modul B...&quot;
...
Langkah N: &quot;Hapus adapter, gunakan API baru secara langsung&quot;
</code></pre>
<h3>Pola 4: Pengembangan fitur dengan pengujian lebih dahulu</h3>
<pre><code>1. &quot;Tulis acceptance test untuk pengeditan profil pengguna&quot;
2. &quot;Tulis unit test untuk validasi profil&quot;
3. &quot;Implementasikan hingga pengujian lulus&quot;
4. &quot;Tambahkan integration test&quot;
5. &quot;Refaktor agar lebih jelas&quot;
</code></pre>
<h2>9) Kapan menggunakan alat yang mana</h2>
<table>
<thead>
<tr>
<th>Situasi</th>
<th>Alat yang Direkomendasikan</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pemrograman real-time, bantuan inline</td>
<td>GitHub Copilot</td>
</tr>
<tr>
<td>Refaktor besar, arsitektur</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Sesi pengembangan penuh</td>
<td>Cursor</td>
</tr>
<tr>
<td>Pertanyaan singkat tentang kode</td>
<td>Antarmuka chat apa pun</td>
</tr>
<tr>
<td>Membuat PR dari issue</td>
<td>Copilot Workspace</td>
</tr>
<tr>
<td>Perubahan kompleks di banyak file</td>
<td>Cursor Agent</td>
</tr>
<tr>
<td>Desain API, dokumentasi</td>
<td>Claude Code</td>
</tr>
<tr>
<td>Mempelajari basis kode baru</td>
<td>Cursor atau Claude Code</td>
</tr>
</tbody>
</table>
<h2>10) Mengukur efektivitas</h2>
<p>Pantau metrik berikut untuk mengevaluasi alat agentik:</p>
<pre><code class="language-javascript">const metrics = {
  // Produktivitas
  suggestionsAccepted: 'persentase saran yang Anda pertahankan',
  timeToFeature: 'jumlah jam dari awal hingga PR digabungkan',
  linesPerHour: 'kecepatan penulisan kode (gunakan dengan hati-hati)',
  
  // Kualitas
  bugsIntroduced: 'cacat yang dilacak berasal dari kode buatan AI',
  testCoverage: 'cakupan pengujian kode buatan AI',
  reviewChanges: 'seberapa banyak Anda mengubah saran AI',
  
  // Biaya
  apiCosts: 'pengeluaran bulanan untuk alat AI',
  creditsUsed: 'kredit Cursor yang digunakan',
  
  // Pembelajaran
  newPatternsLearned: 'teknik yang Anda pelajari dari AI',
  codebaseUnderstanding: 'seberapa baik AI membantu Anda belajar',
}
</code></pre>
<h2>11) Kesalahan umum</h2>
<h3>Terlalu memercayai agen</h3>
<pre><code>❌ Menerima perubahan besar tanpa peninjauan
❌ Membiarkan agen menjalankan perintah destruktif
❌ Tidak memahami kode sebelum merilisnya

✅ Tinjau setiap perubahan
✅ Pahami apa yang Anda rilis
✅ Gunakan agen sebagai rekan kolaborasi, bukan pengganti
</code></pre>
<h3>Pencemaran konteks</h3>
<pre><code>❌ Prompt sangat besar dengan konteks yang tidak relevan
❌ Tidak menghapus konteks di antara tugas yang tidak berkaitan
❌ Menyertakan file hasil generasi dalam konteks

✅ Jaga agar prompt tetap terfokus
✅ Gunakan referensi @file tertentu
✅ Kecualikan artefak build
</code></pre>
<h3>Ambiguitas prompt</h3>
<pre><code>❌ &quot;Buat lebih baik&quot;
❌ &quot;Perbaiki masalahnya&quot;
❌ &quot;Tambahkan beberapa pengujian&quot;

✅ &quot;Kurangi kompleksitas waktu dari O(n²) menjadi O(n log n)&quot;
✅ &quot;Tangani kasus null pada baris 45 dengan mengembalikan lebih awal&quot;
✅ &quot;Tambahkan pengujian untuk kasus khusus: input kosong, panjang maksimum, unicode&quot;
</code></pre>
<h2>12) Daftar periksa untuk memulai</h2>
<pre><code class="language-markdown">## Daftar Periksa Penyiapan

### Claude Code
- [ ] Instal CLI: `npm install -g @anthropic/claude-code`
- [ ] Autentikasi: `claude login`
- [ ] Buat `.claude/config.json`
- [ ] Tambahkan `.claudeignore` untuk file sensitif

### Cursor
- [ ] Unduh dari cursor.com
- [ ] Impor pengaturan VS Code
- [ ] Konfigurasikan `.cursor/rules/`
- [ ] Pelajari `Cmd+Shift+I` untuk mode Agent

### GitHub Copilot
- [ ] Instal ekstensi di IDE Anda
- [ ] Aktifkan Copilot Chat
- [ ] Coba Copilot Workspace untuk issue
- [ ] Konfigurasikan `.github/copilot-instructions.md`

### Semua Alat
- [ ] Siapkan gerbang persetujuan untuk operasi destruktif
- [ ] Buat file ignore untuk rahasia
- [ ] Tetapkan alur kerja peninjauan
- [ ] Pantau metrik sejak hari pertama
</code></pre>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/web-workers-game-logic">Web Worker untuk logika game</a></li>
<li><a href="/id/guides/game-jams-hackathons">Game Jam &amp; Hackathon</a> — alat kode AI mengubah apa yang dapat diwujudkan dalam jam 48 jam</li>
<li><a href="/id/guides/web-game-engines-comparison">Perbandingan Engine Game Web</a> — memilih engine yang cocok dengan alur kerja berbantuan AI</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Analitik dan telemetri untuk game web]]></title>
            <link>https://app.cinevva.com/id/tutorials/analytics-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/analytics-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Lacak hal-hal penting dalam game Anda: perilaku pemain, performa, kesalahan, dan konversi. Ambil keputusan berdasarkan data.]]></description>
            <content:encoded><![CDATA[<h1>Analitik dan telemetri untuk game web</h1>
<p>Analitik membantu Anda memahami apa yang dilakukan pemain, di mana mereka mengalami kesulitan, dan apa yang membuat mereka tetap terlibat. Tutorial ini menunjukkan apa saja yang perlu dilacak dan caranya.</p>
<h2>1) Apa yang perlu dilacak</h2>
<p><strong>Metrik keterlibatan:</strong></p>
<ul>
<li>Awal dan durasi sesi</li>
<li>Level yang dimulai dan diselesaikan</li>
<li>Fitur yang digunakan</li>
<li>Retensi (kunjungan kembali)</li>
</ul>
<p><strong>Metrik performa:</strong></p>
<ul>
<li>Waktu pemuatan</li>
<li>Laju bingkai</li>
<li>Penggunaan memori</li>
<li>Kesalahan dan crash</li>
</ul>
<p><strong>Metrik konversi:</strong></p>
<ul>
<li>Penyelesaian tutorial</li>
<li>Pembelian pertama</li>
<li>Pembagian ke media sosial</li>
</ul>
<h2>2) Pelacakan peristiwa sederhana</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) Pelacakan sesi</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) Peristiwa khusus game</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) Pemantauan performa</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>Temukan penyebab patah-patah dengan Long Animation Frames</h3>
<p>Penghitung FPS di atas memberi tahu Anda <em>kapan</em> laju bingkai menurun, tetapi tidak menjelaskan <em>mengapa</em>. Long Animation Frames API (LoAF), yang tersedia sejak Chrome dan Edge 123, mengisi kekosongan tersebut. API ini menandai setiap bingkai yang memerlukan waktu lebih dari 50 md dan menguraikan skrip mana yang menyebabkan penundaan, sehingga Anda dapat mengaitkan patah-patah dengan kode tertentu alih-alih sekadar menebak.</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>Untuk saat ini, LoAF hanya tersedia di Chromium (belum ada di Firefox atau Safari), jadi lakukan deteksi fitur sebelum menggunakannya dan pertahankan penghitung FPS sebagai patokan lintas peramban Anda.</p>
<h2>6) Pelacakan kesalahan</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) Pelacakan waktu pemuatan</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) Pelacakan funnel</h2>
<p>Lacak kemajuan pemain melalui alur-alur utama:</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) Dukungan pengujian 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) Pertimbangan privasi</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;Kami menggunakan analitik untuk meningkatkan kualitas game. Apakah Anda menyetujuinya?&lt;/p&gt;
    &lt;button id=&quot;accept&quot;&gt;Setuju&lt;/button&gt;
    &lt;button id=&quot;decline&quot;&gt;Tolak&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>Alternatif pihak ketiga</h2>
<p>Jika Anda tidak ingin membuatnya sendiri:</p>
<ul>
<li><strong>Plausible</strong> — Berfokus pada privasi dan sederhana</li>
<li><strong>Amplitude</strong> — Analitik produk dan funnel</li>
<li><strong>Mixpanel</strong> — Pelacakan peristiwa dan perjalanan pengguna</li>
<li><strong>Sentry</strong> — Khusus untuk pelacakan kesalahan</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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/ship-web-game-fast">Merilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/indexeddb-game-saves">Penyimpanan game dengan IndexedDB</a></li>
<li><a href="/id/creators">Untuk kreator</a></li>
<li><a href="/id/guides/itch-io-launch-guide">Cara Meluncurkan Game Anda di itch.io</a> — analitik <a href="http://itch.io">itch.io</a> dan pelacakan unduhan</li>
<li><a href="/id/guides/steam-next-fest-strategy">Strategi Steam Next Fest</a> — mengukur daftar keinginan dan tingkat konversi demo</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://plausible.io/">Plausible Analytics</a> — analitik ringan dan ramah privasi</li>
<li><a href="https://posthog.com/">PostHog</a> — analitik produk sumber terbuka dengan pelacakan peristiwa</li>
<li><a href="https://sentry.io/">Sentry</a> — pemantauan kesalahan dan pelaporan crash untuk aplikasi web</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Dasar-Dasar Game Loop Canvas 2D]]></title>
            <link>https://app.cinevva.com/id/tutorials/canvas-2d-game-loop</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/canvas-2d-game-loop</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Bangun game loop yang solid dengan Canvas 2D. Membahas pengaturan waktu, timestep tetap, input, rendering, dan kesalahan umum yang sering menjebak kreator.]]></description>
            <content:encoded><![CDATA[<h1>Dasar-Dasar Game Loop Canvas 2D</h1>
<p>Game loop adalah detak jantung game Anda. Loop ini berjalan terus-menerus, memperbarui status game dan menggambar frame secepat yang diizinkan browser. Jika Anda menerapkannya dengan benar, semua hal lainnya menjadi lebih mudah. Jika salah, Anda akan mengejar bug yang hanya muncul di perangkat tertentu.</p>
<p><strong>Cobalah (gunakan tombol panah atau WASD):</strong></p>
<GameLoopDemo />
<h2>Versi Paling Sederhana</h2>
<p>Berikut adalah game loop yang disederhanakan hingga hanya menyisakan bagian terpentingnya:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

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

function update() {
  // Logika game di sini
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Menggambar di sini
}

gameLoop()
</code></pre>
<p>Kode ini berfungsi, tetapi memiliki masalah. Fungsi <code>update</code> tidak mengetahui berapa banyak waktu yang telah berlalu sejak frame terakhir. Pada monitor 144 Hz, fungsi tersebut berjalan dua kali lebih cepat dibandingkan pada monitor 60 Hz. Fisika game Anda akan berperilaku berbeda di perangkat yang berbeda.</p>
<h2>Delta Time Mengatasi Masalah Frame Rate</h2>
<p>Agar game berjalan dengan kecepatan yang sama di semua perangkat, Anda perlu melacak berapa banyak waktu yang berlalu di antara frame. Ini disebut delta time, yang biasanya disingkat <code>dt</code>.</p>
<pre><code class="language-js">let lastTime = 0

function gameLoop(currentTime) {
  const dt = (currentTime - lastTime) / 1000 // konversi ke detik
  lastTime = currentTime
  
  update(dt)
  render()
  
  requestAnimationFrame(gameLoop)
}

function update(dt) {
  player.x += player.speed * dt // kini berfungsi pada frame rate apa pun
}

requestAnimationFrame(gameLoop)
</code></pre>
<p>Callback <code>requestAnimationFrame</code> menerima timestamp saat ini dalam milidetik. Saya membaginya dengan 1000 untuk mendapatkan detik karena hal itu mempermudah perhitungan. Kecepatan 200 berarti 200 piksel per detik, yang mudah dipahami.</p>
<p>Satu hal yang perlu diperhatikan: pada frame pertama, <code>lastTime</code> bernilai 0, sehingga <code>dt</code> akan sangat besar. Sebagian orang menginisialisasi <code>lastTime</code> dengan <code>performance.now()</code> sebelum memulai loop, atau melewati pembaruan pada frame pertama.</p>
<h2>Mengapa requestAnimationFrame dan Bukan setInterval</h2>
<p>Anda mungkin bertanya-tanya mengapa kita menggunakan <code>requestAnimationFrame</code> dan bukan <code>setInterval</code>. Ada beberapa alasan.</p>
<p>Pertama, <code>requestAnimationFrame</code> tersinkronisasi dengan refresh rate layar, sehingga Anda mendapatkan animasi yang mulus tanpa tearing. Kedua, fungsi ini otomatis dijeda ketika tab disembunyikan, sehingga menghemat baterai dan CPU. Ketiga, browser dapat mengoptimalkannya dengan lebih baik.</p>
<p>Perilaku jeda tersebut biasanya sesuai dengan yang Anda inginkan, tetapi artinya game akan berhenti ketika pengguna berpindah tab. Jika Anda membuat game multipemain atau sesuatu yang harus terus berjalan, Anda memerlukan pendekatan yang berbeda.</p>
<h2>Timestep Tetap untuk Fisika</h2>
<p>Delta time variabel berfungsi untuk pergerakan sederhana, tetapi dapat menimbulkan masalah pada simulasi fisika. Jika sebuah frame membutuhkan waktu terlalu lama, karakter Anda mungkin menembus dinding karena pemeriksaan tabrakan melewatkan momen benturan.</p>
<p>Solusinya adalah timestep tetap. Anda mengakumulasi waktu dan menjalankan pembaruan fisika dengan laju tetap, terlepas dari seberapa cepat frame dirender.</p>
<pre><code class="language-js">const FIXED_DT = 1 / 60 // 60 pembaruan fisika per detik
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><code>Math.min(..., 0.25)</code> tersebut penting. Jika tab sempat disembunyikan lalu dibuka kembali, <code>frameTime</code> bisa menjadi sangat besar. Tanpa batas tersebut, while loop akan menjalankan ratusan pembaruan untuk mengejar ketertinggalan, sehingga game macet. Membatasinya pada 0,25 detik berarti Anda tidak akan pernah menjalankan lebih dari 15 pembaruan fisika per frame.</p>
<h2>Mengatur Status Game</h2>
<p>Seiring berkembangnya game, Anda memerlukan tempat untuk menyimpan semua data. Saya suka menggunakan satu objek yang menampung semuanya:</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>Pendekatan ini memudahkan Anda menyimpan dan memuat status game, mereset game ketika pemain mati, serta melakukan debug dengan mencatat <code>game</code> ke konsol. Sebagian orang lebih memilih class, tetapi objek biasa sudah cukup untuk game berukuran kecil hingga menengah.</p>
<h2>Menggabungkan Semuanya</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
  
  // Pertahankan agar tetap di dalam batas
  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)
}

// Penanganan input
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>Ini adalah game loop lengkap yang dapat langsung dijalankan. Pemain bergerak menggunakan tombol panah atau WASD, tetap berada di dalam batas, dan semuanya berjalan dengan kecepatan konsisten terlepas dari frame rate. Anda dapat menyalin kode ini sebagai titik awal untuk game Canvas 2D apa pun.</p>
<p>Perhatikan bahwa penanganan input dipisahkan dari game loop. Event listener hanya menetapkan flag, lalu fungsi update membaca flag tersebut. Pemisahan ini penting karena event keyboard dipicu pada waktu yang tidak dapat diprediksi, sedangkan Anda ingin logika game berjalan dengan laju yang dapat diprediksi.</p>
<h2>Memuat dan Menggambar Sprite</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>Gambar dimuat secara asinkron, sehingga Anda perlu menunggunya selesai dimuat sebelum memulai game. Pola yang umum adalah memuat semua sprite dalam fungsi <code>loadAssets</code> yang mengembalikan Promise, lalu memulai game loop hanya setelah Promise tersebut selesai.</p>
<p>Fungsi <code>drawSprite</code> memeriksa apakah sprite tersedia sebelum menggambarnya. Ini mencegah error jika Anda mencoba menggambar sesuatu yang belum selesai dimuat.</p>
<h2>Sprite Beranimasi</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>Class ini mengasumsikan sprite sheet Anda memiliki frame yang disusun secara horizontal dalam satu baris. Setiap frame berukuran sama. Method <code>update</code> berpindah antar-frame berdasarkan waktu yang telah berlalu, sedangkan <code>draw</code> menggunakan versi sembilan argumen dari <code>drawImage</code> untuk memotong hanya frame saat ini.</p>
<p>Anda dapat menggunakannya seperti berikut:</p>
<pre><code class="language-js">const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// Di dalam update:
playerWalk.update(dt)

// Di dalam render:
playerWalk.draw(ctx, player.x, player.y)
</code></pre>
<h2>Deteksi Tabrakan</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
}

// Di dalam update:
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}
</code></pre>
<p>Tabrakan persegi panjang (juga disebut AABB, singkatan dari axis-aligned bounding box) adalah opsi paling sederhana dan cepat. Gunakan untuk sebagian besar objek. Tabrakan lingkaran lebih cocok untuk objek berbentuk bulat atau ketika Anda ingin tabrakan terasa lebih toleran.</p>
<p>Untuk jumlah entitas yang sedikit, memeriksa setiap pasangan tidak menjadi masalah. Jika Anda memiliki ratusan entitas, Anda sebaiknya menggunakan partisi spasial (quadtree atau grid), tetapi itu adalah topik untuk tutorial lain.</p>
<h2>Kamera Sederhana</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)
  
  // Gambar dunia
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // Gambar UI (tidak terpengaruh kamera)
  drawUI()
}
</code></pre>
<p>Kuncinya adalah <code>ctx.save()</code> dan <code>ctx.restore()</code>. Anda menyimpan status transformasi saat ini, menerapkan offset kamera dengan <code>translate</code>, menggambar semuanya dalam ruang dunia, lalu memulihkannya agar elemen UI tidak terpengaruh oleh kamera.</p>
<p>Agar gerakan kamera lebih mulus, Anda dapat melakukan lerp menuju target alih-alih langsung berpindah ke posisinya. Kode seperti <code>this.x += (targetX - this.x) * 0.1</code> akan menghasilkan efek kamera tertinggal yang nyaman.</p>
<h2>Menjeda Game</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('DIJEDA', canvas.width / 2, canvas.height / 2)
}
</code></pre>
<p>Inti utamanya adalah Anda menghentikan akumulasi waktu saat game dijeda, tetapi tetap melakukan rendering. Dengan demikian, overlay jeda langsung muncul dan game tetap merespons input untuk melanjutkan permainan.</p>
<p>Anda dapat memperluas pola ini untuk menangani beberapa status game seperti menu, cutscene, dan layar game over. Cukup periksa status saat ini dan arahkan ke fungsi update/render yang berbeda.</p>
<h2>Kesalahan Umum</h2>
<p>Berikut beberapa hal yang sering saya lihat menjebak kreator:</p>
<p><strong>Lupa membersihkan canvas.</strong> Jika Anda tidak memanggil <code>clearRect</code> pada awal render, Anda akan menggambar di atas frame sebelumnya dan menghasilkan efek gambar berbayang.</p>
<p><strong>Tidak membatasi delta time.</strong> Jika seseorang berpindah tab lalu kembali, delta time yang sangat besar dapat merusak fisika atau membuat game mencoba “mengejar ketertinggalan” selama beberapa detik.</p>
<p><strong>Menggambar sebelum gambar selesai dimuat.</strong> Canvas diam-diam mengabaikan pemanggilan <code>drawImage</code> dengan gambar yang belum selesai dimuat. Sprite Anda tidak akan muncul tanpa pesan error apa pun.</p>
<p><strong>Menggunakan <code>setInterval</code> untuk loop.</strong> Fungsi ini tidak tersinkronisasi dengan layar dan tidak dijeda ketika tab disembunyikan. Gunakan <code>requestAnimationFrame</code>.</p>
<p><strong>Mencampur koordinat dunia dan layar.</strong> Saat menambahkan kamera, perhatikan apakah suatu posisi berada dalam ruang dunia atau ruang layar. Klik mouse diterima dalam ruang layar dan perlu dikonversi.</p>
<h2>Langkah Berikutnya</h2>
<p>Tutorial ini membahas dasar-dasarnya. Setelah memahami pola-pola ini, Anda mungkin ingin mempelajari:</p>
<p><a href="/id/tutorials/responsive-game-canvas">Canvas game responsif</a> untuk menangani ukuran layar yang berbeda dan layar dengan DPI tinggi.</p>
<p><a href="/id/tutorials/game-input-handling">Penanganan input game</a> untuk kontrol sentuh, gamepad, dan sistem input yang lebih canggih.</p>
<p><a href="/id/tutorials/pixel-art-rendering">Rendering pixel art</a> untuk menghasilkan grafis piksel yang tajam tanpa blur.</p>
<p><a href="/id/tutorials/game-physics-libraries">Library fisika game</a> untuk menambahkan fisika benda tegar, tabrakan, dan joint ke game loop Anda.</p>
<p>Jika Anda sedang memilih antara Canvas 2D, WebGL, dan WebGPU untuk proyek Anda, panduan <a href="/id/guides/web-games-stack-2026">Stack Teknologi Game Web pada 2026</a> menguraikan kelebihan dan kekurangannya. <a href="/id/guides/web-game-engines-comparison">Perbandingan Engine Game Web</a> membahas framework yang menangani game loop untuk Anda.</p>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame">MDN: requestAnimationFrame</a> — referensi API peramban</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API">MDN: Canvas API</a> — dokumentasi lengkap Canvas 2D</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent">MDN: KeyboardEvent</a> — kode tombol dan properti peristiwa</li>
<li><a href="https://gafferongames.com/post/fix_your_timestep/">Perbaiki Timestep Anda!</a> — artikel klasik Glenn Fiedler tentang game loop dengan timestep tetap</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Aktifkan thread Wasm (SharedArrayBuffer) dengan COOP/COEP]]></title>
            <link>https://app.cinevva.com/id/tutorials/coop-coep-sharedarraybuffer</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/coop-coep-sharedarraybuffer</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Panduan praktis siap salin-tempel untuk isolasi lintas asal: header yang diperlukan, kendala umum, dan contoh konfigurasi server untuk thread SharedArrayBuffer / WebAssembly. Termasuk pemeriksa gratis untuk menguji apakah suatu URL telah diisolasi lintas asal.]]></description>
            <content:encoded><![CDATA[<h1>Aktifkan thread Wasm (SharedArrayBuffer) dengan COOP/COEP</h1>
<p>Jika game web Anda (atau hasil ekspor engine) menggunakan <strong>thread WebAssembly</strong>, biasanya game tersebut bergantung pada <code>SharedArrayBuffer</code>. Browser modern memerlukan <strong>isolasi lintas asal</strong> untuk itu.</p>
<p><img src="/img/cross-origin-isolation.svg" alt="Diagram: header isolasi lintas asal"></p>
<h2>1) Pemeriksaan cepat: apakah halaman Anda sudah terisolasi?</h2>
<p>Buka DevTools dan jalankan:</p>
<pre><code class="language-js">self.crossOriginIsolated
</code></pre>
<p>Jika nilainya <code>true</code>, Anda berada dalam status keamanan yang tepat untuk <code>SharedArrayBuffer</code>.</p>
<p>Referensi resmi:</p>
<ul>
<li><code>https://developer.mozilla.org/docs/Web/API/Window/crossOriginIsolated</code></li>
</ul>
<h2>2) Dua header yang Anda perlukan</h2>
<p>Sajikan header respons berikut pada HTML Anda (dan biasanya di seluruh aplikasi Anda):</p>
<pre><code class="language-http">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<p>Referensi resmi:</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) Alasan utama mengapa ini “masih tidak berfungsi”: sub-sumber daya</h2>
<p>Dengan <code>COEP: require-corp</code>, halaman Anda tidak lagi dapat menyematkan sumber daya lintas asal secara bebas.</p>
<p>Jika Anda memuat skrip/gambar/worker/video/dll. dari lintas asal, sumber daya tersebut harus memberikan izin melalui:</p>
<ul>
<li>CORS (<code>Access-Control-Allow-Origin</code>) <strong>atau</strong></li>
<li>CORP (<code>Cross-Origin-Resource-Policy</code>)</li>
</ul>
<p>Referensi resmi:</p>
<ul>
<li><code>https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cross-Origin_Resource_Policy</code></li>
</ul>
<h2>4) Konfigurasi siap salin-tempel</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) Daftar periksa debugging</h2>
<ul>
<li>Pastikan header tersedia pada respons <strong>dokumen</strong> di panel Network.</li>
<li>Periksa <code>self.crossOriginIsolated</code>.</li>
<li>Cari permintaan yang diblokir (pesan DevTools yang umum mencantumkan “blocked … by COEP”).</li>
<li>Hapus atau hosting sendiri skrip pihak ketiga yang tidak mendukung CORS/CORP.</li>
</ul>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/guides/web-games-stack-2026">Stack teknologi game web pada 2026</a></li>
<li><a href="/id/tutorials/web-workers-game-logic">Web Worker untuk logika game</a> — menggunakan Worker yang mendapatkan manfaat dari SharedArrayBuffer</li>
<li><a href="/id/tutorials/websocket-multiplayer-basics">Dasar-dasar multipemain WebSocket</a> — game multipemain yang memerlukan isolasi lintas asal</li>
<li><a href="/id/tutorials/iframe-embedding-games">Menyematkan game dengan iframe</a> — pengaruh header COOP/COEP terhadap penyematan iframe</li>
</ul>
<h2>Sumber Daya Eksternal</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> — referensi header 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> — referensi header COEP</li>
<li><a href="https://web.dev/articles/cross-origin-isolation-guide">web.dev: Membuat situs web Anda terisolasi lintas asal</a> — panduan penerapan praktis</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Penanganan input game (keyboard, mouse, sentuhan, gamepad)]]></title>
            <link>https://app.cinevva.com/id/tutorials/game-input-handling</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/game-input-handling</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Penanganan input terpadu untuk game web: dukungan keyboard, mouse, sentuhan, dan gamepad dengan pola kode praktis.]]></description>
            <content:encoded><![CDATA[<h1>Penanganan Input Game</h1>
<p>Penanganan input yang baik dapat menentukan keberhasilan sebuah game. Kontrol yang lambat, input yang tidak terdeteksi, atau pemetaan tombol yang membingungkan akan membuat pemain frustrasi bahkan sebelum mereka melihat konten game Anda. Tutorial ini menunjukkan cara menangani keyboard, mouse, sentuhan, dan gamepad secara terpadu.</p>
<p><strong>Coba terlebih dahulu:</strong></p>
<InputDemo />
<h2>Pola Status Input</h2>
<p>Pendekatan sederhana adalah merespons event secara langsung: ketika pengguna menekan tombol, lakukan sesuatu. Pendekatan ini menimbulkan masalah. Event dipicu pada waktu yang tidak dapat diprediksi, sedangkan game loop Anda berjalan dengan laju tetap. Jika Anda menggerakkan pemain di dalam handler keydown, kecepatan gerakan akan bergantung pada laju pengulangan tombol yang berbeda-beda di setiap sistem operasi.</p>
<p>Sebagai gantinya, pertahankan objek status input yang dibaca oleh game loop Anda:</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>Handler event menetapkan flag tersebut. Game loop membacanya. Pemisahan ini menyederhanakan semuanya: logika pembaruan Anda tidak perlu mengetahui apakah input berasal dari keyboard, sentuhan, atau gamepad.</p>
<h2>Keyboard</h2>
<p>Gunakan <code>e.code</code>, bukan <code>e.key</code>, karena properti tersebut mengacu pada posisi fisik tombol. WASD akan berfungsi dengan benar pada tata letak non-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> mencegah tombol panah menggulir halaman. Panggil metode ini hanya untuk tombol yang Anda tangani.</p>
<h2>Mouse</h2>
<p>Koordinat mouse menggunakan ruang layar. Anda perlu mengonversinya ke ruang canvas:</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>Jika canvas Anda diskalakan (lihat tutorial canvas responsif), Anda perlu membaginya dengan faktor skala untuk mendapatkan koordinat game.</p>
<h2>Sentuhan</h2>
<p>Sentuhan bekerja serupa dengan mouse, tetapi Anda memerlukan <code>e.preventDefault()</code> untuk mencegah browser menggulir atau memperbesar halaman:</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><code>{ passive: false }</code> diperlukan karena browser modern secara default menggunakan listener sentuhan pasif demi performa. Tanpanya, <code>preventDefault</code> tidak akan berfungsi.</p>
<h2>D-Pad Virtual untuk Perangkat Seluler</h2>
<p>Untuk game yang memerlukan kontrol arah di perangkat seluler, tambahkan tombol di layar:</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>Atur gaya tombol-tombol ini agar cukup besar untuk ditekan dengan ibu jari (setidaknya 44x44 piksel), lalu posisikan di sudut bawah.</p>
<h2>Gamepad</h2>
<p>Berbeda dari keyboard dan mouse, status gamepad tidak digerakkan oleh event. Anda harus melakukan polling pada setiap frame:</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>Panggil <code>pollGamepads()</code> pada awal fungsi pembaruan Anda. Ambang batas 0,5 pada stik analog menciptakan zona mati agar gerakan kecil pada stik tidak terdeteksi sebagai input.</p>
<p>Anda dapat mendeteksi saat gamepad tersambung dan terputus:</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>Menggunakan Input dalam Game Loop Anda</h2>
<p>Sekarang fungsi pembaruan Anda cukup membaca status input:</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>Cara ini berfungsi terlepas dari apakah input berasal dari keyboard, sentuhan, atau gamepad. Logika game tidak perlu mengetahuinya.</p>
<h2>Baru Ditekan vs Ditahan</h2>
<p>Terkadang Anda ingin mendeteksi saat tombol baru saja ditekan pada frame ini, bukan apakah tombol tersebut sedang ditahan. Melompat adalah contoh yang tepat: Anda menginginkan satu lompatan untuk setiap penekanan, bukan lompatan terus-menerus selama tombol ditahan.</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>Panggil <code>endFrame()</code> pada akhir loop pembaruan Anda. Kemudian gunakan <code>wasJustPressed('action')</code>, bukan <code>input.action</code>, untuk tindakan yang hanya dijalankan sekali.</p>
<h2>Kehilangan Fokus</h2>
<p>Saat jendela browser kehilangan fokus, event keyboard berhenti dipicu. Jika pemain sedang menahan tombol, statusnya akan terus aktif:</p>
<pre><code class="language-js">window.addEventListener('blur', () =&gt; {
  input.left = input.right = input.up = input.down = input.action = false
})
</code></pre>
<p>Tanpa penanganan ini, pemain akan terus bergerak ketika pengguna beralih jendela dengan alt-tab.</p>
<h2>Praktik Terbaik</h2>
<p>Dukung WASD dan tombol panah karena setiap pemain memiliki preferensi. Izinkan pemetaan ulang tombol dan simpan konfigurasinya di localStorage. Sediakan kontrol sentuh pada perangkat seluler. Uji dengan gamepad sungguhan karena kontroler Xbox, PlayStation, dan generik memiliki perilaku yang sedikit berbeda. Dan selalu tangani hilangnya fokus dengan baik.</p>
<h2>Referensi Lainnya</h2>
<p><a href="/id/tutorials/gamepad-api">Pembahasan mendalam Gamepad API</a> membahas fitur gamepad tingkat lanjut seperti getaran dan dukungan beberapa pemain.</p>
<p><a href="/id/tutorials/mobile-friendly-web-games">Game web yang ramah perangkat seluler</a> membahas input sentuh secara lebih mendetail.</p>
<p><a href="/id/tutorials/pointer-lock-fps">Pointer Lock untuk game FPS</a> menunjukkan cara menangkap mouse untuk kontrol orang pertama.</p>
<p><a href="/id/tutorials/canvas-2d-game-loop">Game loop Canvas 2D</a> menunjukkan pola status input yang diintegrasikan ke dalam game loop lengkap.</p>
<p><a href="/id/tutorials/websocket-multiplayer-basics">Dasar-dasar multipemain WebSocket</a> membahas sinkronisasi input di antara pemain dalam jaringan.</p>
<h2>Referensi Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code">MDN: KeyboardEvent.code</a> — nilai tombol fisik untuk pemetaan input</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events">MDN: Event pointer</a> — event mouse dan sentuhan terpadu</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN: Gamepad API</a> — dukungan kontroler di browser</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN: Event sentuhan</a> — penanganan multisentuh di perangkat seluler</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fisika gim untuk gim web]]></title>
            <link>https://app.cinevva.com/id/tutorials/game-physics-libraries</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/game-physics-libraries</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Bandingkan mesin fisika untuk gim web: Rapier, Cannon-es, Ammo.js, Matter.js, Planck.js. Tolok ukur performa, contoh kode, dan kapan menggunakan masing-masing.]]></description>
            <content:encoded><![CDATA[<h1>Fisika gim untuk gim web</h1>
<p>Simulasi fisika membuat gim terasa hidup—objek jatuh, bola memantul, ragdoll, kendaraan, dan lingkungan yang dapat dihancurkan. Panduan ini membahas pustaka fisika utama yang tersedia untuk gim web, lengkap dengan contoh kode nyata serta kelebihan dan kekurangan yang jujur.</p>
<h2>Sekilas</h2>
<table>
<thead>
<tr>
<th>Mesin</th>
<th style="text-align:center">Dimensi</th>
<th style="text-align:center">Performa</th>
<th style="text-align:center">Ukuran</th>
<th style="text-align:center">Tingkat kesulitan</th>
<th style="text-align:center">Benda Lunak</th>
<th style="text-align:center">Kendaraan</th>
<th style="text-align:center">CCD</th>
<th style="text-align:center">Determinisme</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">Sedang</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">Mudah</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">Sulit</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">Sulit</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">Mudah</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">Mudah</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">Sedang</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">Sedang</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">Sulit</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>Keterangan:</strong></p>
<ul>
<li>CCD = Deteksi Tabrakan Berkelanjutan (mencegah objek cepat menembus dinding)</li>
<li>⚠️ Determinisme = Dapat dicapai dengan timestep tetap, tetapi tidak dijamin lintas platform</li>
</ul>
<h3>Rekomendasi singkat</h3>
<table>
<thead>
<tr>
<th>Situasi Anda</th>
<th>Pilihan terbaik</th>
</tr>
</thead>
<tbody>
<tr>
<td>Gim 3D produksi</td>
<td><strong>Rapier</strong> — performa terbaik, API modern, pengembangan aktif</td>
</tr>
<tr>
<td>Belajar / purwarupa</td>
<td><strong>Cannon-es</strong> (3D) atau <strong>Matter.js</strong> (2D) — API sederhana, mudah di-debug</td>
</tr>
<tr>
<td>Fisika kendaraan</td>
<td><strong>Rapier</strong> atau <strong>Ammo.js</strong> — keduanya memiliki pengontrol kendaraan berbasis ray cast</td>
</tr>
<tr>
<td>Benda lunak, kain, tali</td>
<td><strong>Ammo.js</strong> atau <strong>Jolt (JoltPhysics.js)</strong> — keduanya mendukung kain dan benda lunak; port WASM Jolt dipelihara dengan lebih aktif</td>
</tr>
<tr>
<td>Platformer 2D presisi</td>
<td><strong>Planck.js</strong> — algoritma Box2D, deterministik dengan timestep tetap</td>
</tr>
<tr>
<td>Performa 2D maksimum</td>
<td><strong>Box2D WASM</strong> — kecepatan native di browser</td>
</tr>
<tr>
<td>Ukuran bundel terkecil</td>
<td><strong>Oimo.js</strong> (3D) atau <strong>Matter.js</strong> (2D)</td>
</tr>
</tbody>
</table>
<hr>
<h2>Perbandingan mendetail</h2>
<h3>Mesin Fisika 3D</h3>
<table>
<thead>
<tr>
<th>Mesin</th>
<th>Bahasa</th>
<th>Ukuran</th>
<th>Performa</th>
<th>Terbaik untuk</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Rapier</strong></td>
<td>Rust/WASM</td>
<td>~1.4 MB</td>
<td>Sangat baik</td>
<td>Gim produksi, simulasi kompleks</td>
</tr>
<tr>
<td><strong>Cannon-es</strong></td>
<td>JavaScript</td>
<td>~150 KB</td>
<td>Baik</td>
<td>Purwarupa, gim sederhana</td>
</tr>
<tr>
<td><strong>Ammo.js</strong></td>
<td>C++/WASM</td>
<td>~1-2 MB</td>
<td>Sangat baik</td>
<td>Fitur AAA, benda lunak, kendaraan</td>
</tr>
<tr>
<td><strong>Jolt</strong></td>
<td>C++/WASM</td>
<td>WASM</td>
<td>Sangat baik</td>
<td>Fitur AAA dengan pemeliharaan aktif</td>
</tr>
<tr>
<td><strong>Oimo.js</strong></td>
<td>JavaScript</td>
<td>~100 KB</td>
<td>Baik</td>
<td>Gim sederhana, purwarupa cepat</td>
</tr>
</tbody>
</table>
<h3>Mesin Fisika 2D</h3>
<table>
<thead>
<tr>
<th>Mesin</th>
<th>Bahasa</th>
<th>Ukuran</th>
<th>Performa</th>
<th>Terbaik untuk</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Matter.js</strong></td>
<td>JavaScript</td>
<td>~80 KB</td>
<td>Baik</td>
<td>Gim visual, purwarupa</td>
</tr>
<tr>
<td><strong>Planck.js</strong></td>
<td>JavaScript</td>
<td>~120 KB</td>
<td>Baik</td>
<td>Platformer, fisika presisi</td>
</tr>
<tr>
<td><strong>p2-es</strong></td>
<td>JavaScript</td>
<td>~100 KB</td>
<td>Baik</td>
<td>Constraint, mekanisme</td>
</tr>
<tr>
<td><strong>Box2D WASM</strong></td>
<td>C++/WASM</td>
<td>~300 KB</td>
<td>Sangat baik</td>
<td>Jumlah benda yang besar</td>
</tr>
</tbody>
</table>
<hr>
<h2>Mesin Fisika 3D</h2>
<h3>Rapier — Pilihan modern</h3>
<p>Rapier adalah mesin fisika Rust dengan binding JavaScript/WASM. Ini merupakan opsi dengan performa terbaik untuk gim web pada 2025-2026, dengan peningkatan kecepatan 2-5 kali lipat dibandingkan versi 2024.</p>
<p><strong>Instalasi:</strong></p>
<pre><code class="language-bash">npm install @dimforge/rapier3d
# Or with SIMD (faster, requires modern browsers):
npm install @dimforge/rapier3d-simd
</code></pre>
<p><strong>Penyiapan dasar:</strong></p>
<pre><code class="language-js">import RAPIER from '@dimforge/rapier3d'

// Initialize (async required for WASM)
await RAPIER.init()

// Create world with gravity
const gravity = { x: 0, y: -9.81, z: 0 }
const world = new RAPIER.World(gravity)

// Create ground (static body)
const groundDesc = RAPIER.RigidBodyDesc.fixed()
const groundBody = world.createRigidBody(groundDesc)
const groundCollider = RAPIER.ColliderDesc.cuboid(50, 0.1, 50)
world.createCollider(groundCollider, groundBody)

// Create falling box (dynamic body)
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>Integrasi game loop:</strong></p>
<pre><code class="language-js">const FIXED_TIMESTEP = 1 / 60

function physicsStep() {
  world.step()
}

function gameLoop() {
  physicsStep()
  
  // Sync render objects with physics
  const position = boxBody.translation()
  const rotation = boxBody.rotation()
  
  // Update your Three.js/Babylon mesh
  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>Deteksi tabrakan:</strong></p>
<pre><code class="language-js">// Event-based collision detection
world.contactPairsWith(boxCollider, (otherCollider) =&gt; {
  console.log('Box is touching:', 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('Hit at:', hitPoint)
}
</code></pre>
<p><strong>Joint:</strong></p>
<pre><code class="language-js">// Create a hinge joint (door)
const jointData = RAPIER.JointData.revolute(
  { x: 0, y: 0, z: 0 },  // Anchor on body1
  { x: -1, y: 0, z: 0 }, // Anchor on body2
  { x: 0, y: 1, z: 0 }   // Rotation axis
)
world.createImpulseJoint(jointData, body1, body2, true)
</code></pre>
<p><strong>Pengontrol kendaraan:</strong></p>
<pre><code class="language-js">// Create chassis rigid body
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)

// Create vehicle controller
const vehicle = world.createVehicleController(chassis)

// Add wheels (front-left, front-right, rear-left, rear-right)
const suspensionRestLength = 0.3
const wheelRadius = 0.4

// Front wheels (steering)
vehicle.addWheel(
  { x: -0.8, y: 0, z: 1.2 },   // Connection point
  { x: 0, y: -1, z: 0 },       // Suspension direction
  { x: -1, y: 0, z: 0 },       // Axle direction
  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
)

// Rear wheels (drive)
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
)

// Configure suspension for all wheels
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)
}

// In game loop
function updateVehicle(steering, engineForce, brakeForce) {
  // Steering (front wheels only)
  vehicle.setWheelSteering(0, steering)
  vehicle.setWheelSteering(1, steering)
  
  // Engine (rear wheels)
  vehicle.setWheelEngineForce(2, engineForce)
  vehicle.setWheelEngineForce(3, engineForce)
  
  // Brakes (all wheels)
  for (let i = 0; i &lt; 4; i++) {
    vehicle.setWheelBrake(i, brakeForce)
  }
  
  // Update vehicle physics
  vehicle.updateVehicle(world.timestep)
}
</code></pre>
<p><strong>Kelebihan:</strong></p>
<ul>
<li>Performa terbaik (WASM + SIMD)</li>
<li>Akurasi deteksi tabrakan yang sangat baik</li>
<li>Determinisme lintas platform (input sama = output sama)</li>
<li>Pengembangan aktif, API modern</li>
<li>Deteksi tabrakan berkelanjutan (tanpa tunneling)</li>
<li>Pengontrol karakter dan kendaraan bawaan</li>
</ul>
<p><strong>Kekurangan:</strong></p>
<ul>
<li>Ukuran bundel lebih besar (~1.4 MB)</li>
<li>Memerlukan inisialisasi asinkron</li>
<li>API lebih kompleks daripada alternatif JS murni</li>
<li>Debugging WASM dapat terasa rumit</li>
</ul>
<hr>
<h3>Cannon-es — Sederhana dan efektif</h3>
<p>Cannon-es adalah fork Cannon.js yang masih dipelihara. Dibuat dengan JavaScript murni, mudah dipahami, dan sangat cocok untuk belajar serta membuat purwarupa.</p>
<p><strong>Instalasi:</strong></p>
<pre><code class="language-bash">npm install cannon-es
</code></pre>
<p><strong>Penyiapan dasar:</strong></p>
<pre><code class="language-js">import * as CANNON from 'cannon-es'

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

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

// Falling sphere
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>Game loop:</strong></p>
<pre><code class="language-js">const TIMESTEP = 1 / 60

function animate() {
  world.step(TIMESTEP)
  
  // Sync with Three.js
  mesh.position.copy(sphereBody.position)
  mesh.quaternion.copy(sphereBody.quaternion)
  
  requestAnimationFrame(animate)
}
</code></pre>
<p><strong>Event tabrakan:</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('Hard impact!')
  }
})
</code></pre>
<p><strong>Constraint:</strong></p>
<pre><code class="language-js">// Distance constraint (rope-like)
const constraint = new CANNON.DistanceConstraint(
  bodyA, bodyB, 
  2 // distance
)
world.addConstraint(constraint)

// Hinge constraint
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>Kelebihan:</strong></p>
<ul>
<li>JavaScript murni, tanpa kompleksitas WASM</li>
<li>Ukuran bundel kecil (~150 KB)</li>
<li>Mudah dipelajari dan di-debug</li>
<li>Berfungsi di mana saja</li>
<li>Integrasi Three.js yang sangat baik</li>
<li>Dokumentasi yang baik</li>
</ul>
<p><strong>Kekurangan:</strong></p>
<ul>
<li>Lebih lambat daripada alternatif WASM</li>
<li>Kesulitan menangani banyak benda (&gt;100)</li>
<li>Dukungan trimesh terbatas</li>
<li>Tidak ada CCD bawaan (tunneling dapat terjadi)</li>
<li>Pengembangan kurang aktif</li>
</ul>
<hr>
<h3>Ammo.js — Kekuatan penuh Bullet Physics</h3>
<p>Ammo.js adalah mesin Bullet Physics yang dikompilasi ke WebAssembly. Menawarkan fitur paling lengkap, termasuk benda lunak, kendaraan, dan constraint tingkat lanjut.</p>
<p><strong>Instalasi:</strong></p>
<pre><code class="language-bash">npm install ammo.js
# Or use from CDN
</code></pre>
<p><strong>Penyiapan dasar:</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>Benda lunak (kain):</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>Fisika kendaraan:</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)
  
  // Add wheels
  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))   // Front left
  addWheel(true, new Ammo.btVector3(-1, 0, 1.5))  // Front right
  addWheel(false, new Ammo.btVector3(1, 0, -1.5)) // Rear left
  addWheel(false, new Ammo.btVector3(-1, 0, -1.5))// Rear right
  
  return vehicle
}
</code></pre>
<p><strong>Kelebihan:</strong></p>
<ul>
<li>Kumpulan fitur paling lengkap</li>
<li>Benda lunak, kain, tali</li>
<li>Fisika kendaraan tingkat lanjut</li>
<li>Teruji di berbagai gim (digunakan dalam banyak gim AAA)</li>
<li>Sangat dapat dikonfigurasi
<strong>Kekurangan:</strong></li>
<li>API kompleks dan bertele-tele</li>
<li>Ukuran bundel besar</li>
<li>Memerlukan pengelolaan memori (menghancurkan objek)</li>
<li>Kurva belajar terjal</li>
<li>Dokumentasi tersebar</li>
<li>Tidak deterministik lintas platform (hanya pada perangkat yang sama, dengan konfigurasi cermat)</li>
</ul>
<hr>
<h3>Jolt — fisika 3D modern kelas AAA</h3>
<p>Jolt Physics adalah engine di balik game seperti Horizon Forbidden West, dan JoltPhysics.js menghadirkannya ke browser sebagai port WASM. Jolt menjadi jalan tengah yang kuat antara Rapier dan Ammo.js: fiturnya lebih lengkap daripada Rapier (benda lunak, kain, pengontrol kendaraan beroda), dengan pemeliharaan aktif yang tidak dimiliki Ammo.js. Paket npm-nya adalah <code>jolt-physics</code>, dan tersedia integrasi siap pakai untuk React Three Fiber (<code>@react-three/jolt</code>) dan Babylon.js.</p>
<pre><code class="language-bash">npm install jolt-physics
</code></pre>
<p>Seperti Rapier, Jolt menggunakan WASM yang diinisialisasi secara asinkron dengan API panjang tetapi lengkap yang mencerminkan antarmuka C++. Gunakan Jolt ketika Anda menginginkan fitur sekelas Bullet (benda lunak, kendaraan), tetapi lebih memilih basis kode modern yang terpelihara daripada Ammo.js.</p>
<hr>
<h3>Oimo.js — Ringan dan cepat</h3>
<p>Oimo.js adalah engine fisika 3D yang ringan. Cocok untuk game sederhana yang tidak memerlukan fitur canggih.</p>
<p><strong>Instalasi:</strong></p>
<pre><code class="language-bash">npm install oimo
</code></pre>
<p><strong>Penyiapan dasar:</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: brute, 2: sweep &amp; prune, 3: volume tree
  worldscale: 1,
  random: true,
  gravity: [0, -9.8, 0]
})

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

// Create falling sphere
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>Loop game:</strong></p>
<pre><code class="language-js">function animate() {
  world.step()
  
  // Get position/rotation
  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>Kelebihan:</strong></p>
<ul>
<li>Sangat kecil (~100 KB)</li>
<li>API sederhana</li>
<li>Performa bagus untuk adegan dasar</li>
<li>Dukungan bawaan untuk Babylon.js</li>
</ul>
<p><strong>Kekurangan:</strong></p>
<ul>
<li>Bentuk terbatas (hanya bentuk primitif)</li>
<li>Tidak ada benda lunak</li>
<li>Dokumentasi minim</li>
<li>Pengembangan kurang aktif</li>
<li>Pilihan sambungan terbatas</li>
</ul>
<hr>
<h2>Engine Fisika 2D</h2>
<h3>Matter.js — Indah dan intuitif</h3>
<p>Matter.js adalah engine fisika 2D kaya fitur dengan alat rendering dan debug yang sangat baik.</p>
<p><strong>Instalasi:</strong></p>
<pre><code class="language-bash">npm install matter-js
</code></pre>
<p><strong>Penyiapan dasar:</strong></p>
<pre><code class="language-js">import Matter from 'matter-js'

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

// Create engine
const engine = Engine.create()

// Create renderer (optional, great for debugging)
const render = Render.create({
  element: document.body,
  engine: engine,
  options: {
    width: 800,
    height: 600,
    wireframes: false
  }
})

// Create bodies
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
})

// Add to world
World.add(engine.world, [ground, box, circle])

// Run
Render.run(render)
Runner.run(Runner.create(), engine)
</code></pre>
<p><strong>Peristiwa tabrakan:</strong></p>
<pre><code class="language-js">Matter.Events.on(engine, 'collisionStart', (event) =&gt; {
  event.pairs.forEach(pair =&gt; {
    console.log('Collision between:', pair.bodyA.label, pair.bodyB.label)
  })
})
</code></pre>
<p><strong>Constraint:</strong></p>
<pre><code class="language-js">// Pin constraint
const pin = Matter.Constraint.create({
  pointA: { x: 400, y: 100 },
  bodyB: box,
  stiffness: 0.9
})

// Spring between bodies
const spring = Matter.Constraint.create({
  bodyA: box,
  bodyB: circle,
  stiffness: 0.01,
  length: 100
})

World.add(engine.world, [pin, spring])
</code></pre>
<p><strong>Interaksi mouse:</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>Kelebihan:</strong></p>
<ul>
<li>Rendering bawaan yang indah</li>
<li>Sangat cocok untuk belajar dan membuat prototipe</li>
<li>API intuitif</li>
<li>Dokumentasi bagus</li>
<li>Komunitas aktif</li>
<li>Deterministik secara bawaan — Matter.Runner kini menggunakan timestep tetap yang deterministik (timestep tidak tetap dihapus pada v0.20.0)</li>
</ul>
<p><strong>Kekurangan:</strong></p>
<ul>
<li>Tidak ada CCD (objek cepat dapat menembus objek lain — gunakan substepping untuk menguranginya)</li>
<li>Masalah performa saat terdapat banyak benda</li>
<li>Tidak ada opsi WASM</li>
<li>Presisi terbatas untuk simulasi kompleks</li>
</ul>
<hr>
<h3>Planck.js — Box2D untuk JavaScript</h3>
<p>Planck.js adalah penulisan ulang lengkap Box2D dalam JavaScript. Sistem fisikanya telah teruji dan deterministik saat menggunakan timestep tetap.</p>
<p><strong>Instalasi:</strong></p>
<pre><code class="language-bash">npm install planck
</code></pre>
<p><strong>Penyiapan dasar:</strong></p>
<pre><code class="language-js">import { World, Vec2, Box, Circle, Edge } from 'planck'

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

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

// Create dynamic box
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>Loop game dengan timestep tetap:</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)
  
  // Iterate all bodies
  for (let body = world.getBodyList(); body; body = body.getNext()) {
    const pos = body.getPosition()
    const angle = body.getAngle()
    // Update your sprites...
  }
  
  requestAnimationFrame(gameLoop)
}
</code></pre>
<p><strong>Callback tabrakan:</strong></p>
<pre><code class="language-js">world.on('begin-contact', (contact) =&gt; {
  const fixtureA = contact.getFixtureA()
  const fixtureB = contact.getFixtureB()
  console.log('Contact started')
})

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

world.on('pre-solve', (contact, oldManifold) =&gt; {
  // Can disable contact here
  // contact.setEnabled(false)
})
</code></pre>
<p><strong>Sambungan:</strong></p>
<pre><code class="language-js">import { RevoluteJoint, DistanceJoint, PrismaticJoint } from 'planck'

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

// Distance joint (spring)
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>Kelebihan:</strong></p>
<ul>
<li>Deterministik dengan timestep tetap</li>
<li>Terdokumentasi dengan baik (dokumentasi Box2D juga berlaku)</li>
<li>Performa bagus</li>
<li>Dukungan TypeScript</li>
<li>Ukuran bundel kecil</li>
</ul>
<p><strong>Kekurangan:</strong></p>
<ul>
<li>CCD tidak menangani sambungan, sehingga objek cepat yang terhubung dengan sambungan dapat meregang</li>
<li>Kurva belajar lebih terjal daripada Matter.js</li>
<li>Tidak ada renderer bawaan</li>
<li>Keunikan Box2D (skala satuan sangat berpengaruh)</li>
</ul>
<hr>
<h3>p2-es — Fisika 2D yang fleksibel</h3>
<p>p2-es adalah fork p2.js yang masih dipelihara. Cocok untuk game yang memerlukan constraint dan mekanisme kompleks.</p>
<p><strong>Instalasi:</strong></p>
<pre><code class="language-bash">npm install p2-es
</code></pre>
<p><strong>Penyiapan dasar:</strong></p>
<pre><code class="language-js">import * as p2 from 'p2-es'

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

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

// Dynamic circle
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>Constraint tingkat lanjut:</strong></p>
<pre><code class="language-js">// Gear constraint
const gear = new p2.GearConstraint(bodyA, bodyB, {
  ratio: 2 // bodyB rotates twice as fast
})
world.addConstraint(gear)

// Prismatic constraint (slider)
const prismatic = new p2.PrismaticConstraint(bodyA, bodyB, {
  localAnchorA: [0, 0],
  localAnchorB: [0, 0],
  localAxisA: [1, 0],
  disableRotationalLock: false
})
world.addConstraint(prismatic)

// Lock constraint (weld)
const lock = new p2.LockConstraint(bodyA, bodyB)
world.addConstraint(lock)
</code></pre>
<p><strong>Material kontak:</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)

// Apply to shapes
iceBody.shapes[0].material = ice
rubberBody.shapes[0].material = rubber
</code></pre>
<p><strong>Kelebihan:</strong></p>
<ul>
<li>Sistem constraint yang kaya</li>
<li>Material kontak</li>
<li>Cocok untuk mekanisme dan mesin</li>
<li>Benda dapat dinonaktifkan saat diam (meningkatkan performa)</li>
<li>Modul ES dan mendukung tree-shaking</li>
</ul>
<p><strong>Kekurangan:</strong></p>
<ul>
<li>Tidak ada renderer bawaan</li>
<li>Beberapa pasangan bentuk tidak didukung</li>
<li>Kurang aktif dibandingkan alternatifnya</li>
<li>Dokumentasi belum lengkap</li>
</ul>
<hr>
<h2>Kiat performa</h2>
<h3>1. Gunakan timestep tetap</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
  }
  
  // Interpolate for smooth rendering
  const alpha = accumulator / TIMESTEP
  // lerp(previousState, currentState, alpha)
}
</code></pre>
<h3>2. Nonaktifkan benda yang tidak bergerak</h3>
<p>Sebagian besar engine mendukung mode tidur. Aktifkan fitur ini:</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. Gunakan bentuk tabrakan sederhana</h3>
<pre><code class="language-js">// GOOD: Simple primitives
const sphere = new CANNON.Sphere(1)
const box = new CANNON.Box(new CANNON.Vec3(1, 1, 1))

// AVOID: Complex trimesh for dynamic bodies
const trimesh = new CANNON.Trimesh(vertices, indices) // Slow!
</code></pre>
<h3>4. Kurangi iterasi solver (dengan hati-hati)</h3>
<pre><code class="language-js">// Cannon-es
world.solver.iterations = 5 // Default 10

// Rapier - configured at world creation
</code></pre>
<h3>5. Jalankan fisika di 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
  // Update render objects
}

// 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>Kapan harus menggunakan apa</h2>
<table>
<thead>
<tr>
<th>Skenario</th>
<th>Rekomendasi</th>
<th>Alasan</th>
</tr>
</thead>
<tbody>
<tr>
<td>Game 3D produksi</td>
<td><strong>Rapier</strong></td>
<td>Performa terbaik, API modern, kendaraan</td>
</tr>
<tr>
<td>Prototipe game 3D</td>
<td><strong>Cannon-es</strong></td>
<td>Sederhana, mudah di-debug</td>
</tr>
<tr>
<td>Benda lunak, kain, tali</td>
<td><strong>Ammo.js</strong> atau <strong>Jolt</strong></td>
<td>Keduanya mendukung kain/benda lunak; port WASM Jolt (JoltPhysics.js) dipelihara lebih aktif</td>
</tr>
<tr>
<td>Game browser 3D sederhana</td>
<td><strong>Oimo.js</strong></td>
<td>Sangat kecil dan memadai</td>
</tr>
<tr>
<td>Game jam 2D</td>
<td><strong>Matter.js</strong></td>
<td>Penyiapan cepat, rendering bawaan</td>
</tr>
<tr>
<td>Platformer presisi</td>
<td><strong>Planck.js</strong></td>
<td>Determinisme timestep tetap, terdokumentasi dengan baik</td>
</tr>
<tr>
<td>Mekanisme 2D kompleks</td>
<td><strong>p2-es</strong></td>
<td>Sistem constraint yang kaya</td>
</tr>
<tr>
<td>Game 2D yang mengutamakan performa</td>
<td><strong>Box2D WASM</strong></td>
<td>Opsi 2D tercepat, CCD sesungguhnya</td>
</tr>
</tbody>
</table>
<hr>
<h2>Integrasi dengan renderer</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() // physics body -&gt; mesh

function createPhysicsBox(x, y, z) {
  // Physics
  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)
  
  // Render
  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) {
  // Physics
  const body = Matter.Bodies.rectangle(x, y, width, height)
  Matter.World.add(engine.world, body)
  
  // Render
  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>Kesalahan umum</h2>
<h3>Skala sangat berpengaruh</h3>
<p>Engine fisika bekerja paling baik dengan skala dunia nyata (1 satuan = 1 meter). Jangan gunakan koordinat piksel secara langsung.</p>
<pre><code class="language-js">// BAD: Using pixel positions
const body = Bodies.circle(400, 300, 50) // 50 pixel radius?

// GOOD: Use a scale factor
const SCALE = 50 // 50 pixels per meter
const body = Bodies.circle(8, 6, 1) // 1 meter radius
// Then multiply by SCALE when rendering
</code></pre>
<h3>Kebocoran memori pada engine WASM</h3>
<p>Selalu bersihkan benda saat menghapusnya:</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>Tunneling (objek saling menembus)</h3>
<p>Objek yang bergerak cepat dapat menembus dinding tipis. Solusinya:</p>
<pre><code class="language-js">// Rapier: Enable CCD
const bodyDesc = RAPIER.RigidBodyDesc.dynamic().setCcdEnabled(true)

// Cannon-es: Use smaller timesteps or thicker walls
world.step(1/120) // 120 Hz instead of 60
</code></pre>
<hr>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/webgl-fundamentals-for-games">Dasar-dasar WebGL untuk pengembang game</a></li>
<li><a href="/id/tutorials/web-workers-game-logic">Web Worker untuk logika game</a> — alihkan pemrosesan fisika ke thread Worker</li>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/canvas-2d-game-loop">Game loop Canvas 2D</a> — pola timestep tetap yang digunakan oleh setiap mesin fisika</li>
<li><a href="/id/guides/web-games-stack-2026">Stack Teknologi Game Web pada 2026</a> — bagaimana mesin fisika menjadi bagian dari stack yang lebih luas</li>
<li><a href="/id/guides/web-game-engines-comparison">Perbandingan Mesin Game Web</a> — mesin game dengan dukungan fisika bawaan</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://rapier.rs/docs/">Dokumentasi Rapier</a> — dokumentasi dan contoh resmi fisika Rapier</li>
<li><a href="https://pmndrs.github.io/cannon-es/docs/">Dokumentasi Cannon-es</a> — referensi API dan panduan</li>
<li><a href="https://brm.io/matter-js/docs/">Dokumentasi Matter.js</a> — referensi API lengkap</li>
<li><a href="https://piqnt.com/planck.js/">Dokumentasi Planck.js</a> — Box2D untuk JavaScript</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Gamepad API untuk game web]]></title>
            <link>https://app.cinevva.com/id/tutorials/gamepad-api</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/gamepad-api</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Dukungan gamepad lengkap: deteksi, pemetaan tombol, stik analog, getaran, dan penanganan beberapa kontroler. Input berkualitas konsol di web.]]></description>
            <content:encoded><![CDATA[<h1>Gamepad API untuk game web</h1>
<p>Gamepad API menghadirkan dukungan kontroler berkualitas konsol untuk game web. Dukung kontroler Xbox, PlayStation, Switch Pro, dan kontroler generik.</p>
<h2>1) Mendeteksi gamepad</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) Melakukan polling status gamepad</h2>
<p>Status gamepad harus diperiksa melalui polling (bukan berbasis event):</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) Pemetaan tombol standar</h2>
<p>Pemetaan &quot;standard&quot; (sebagian besar kontroler Xbox/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) Membaca tombol dan stik</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) Menangani deadzone</h2>
<p>Stik jarang berada tepat di posisi 0 saat dilepas:</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) Deadzone radial (lebih baik untuk pergerakan 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) Pengelola gamepad lengkap</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) Getaran / umpan balik haptik</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) Beberapa pemain</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) Input cadangan dan gabungan</h2>
<p>Dukung keyboard dan gamepad sekaligus:</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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/game-input-handling">Penanganan input game</a></li>
<li><a href="/id/tutorials/mobile-friendly-web-games">Game web ramah perangkat seluler</a></li>
<li><a href="/id/tutorials/canvas-2d-game-loop">Game loop Canvas 2D</a></li>
<li><a href="/id/tutorials/pointer-lock-fps">Pointer Lock untuk game FPS</a> — menggabungkan gamepad dengan kontrol pandangan menggunakan mouse</li>
<li><a href="/id/guides/co-op-game-design">Desain Game Co-op</a> — merancang input dari beberapa kontroler untuk co-op lokal</li>
</ul>
<h2>Sumber Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API">MDN: Gamepad API</a> — referensi API lengkap</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API">MDN: Menggunakan Gamepad API</a> — panduan langkah demi langkah</li>
<li><a href="https://gamepad-tester.com/">Penguji Gamepad</a> — uji pemetaan kontroler Anda di browser</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Praktik terbaik penyematan game dengan iframe]]></title>
            <link>https://app.cinevva.com/id/tutorials/iframe-embedding-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/iframe-embedding-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Sematkan game web dalam iframe dengan benar: keamanan, komunikasi, layar penuh, dan persyaratan platform. Jadikan game Anda dapat disematkan di mana saja.]]></description>
            <content:encoded><![CDATA[<h1>Praktik terbaik penyematan game dengan iframe</h1>
<p>Banyak platform (<a href="http://Itch.io">Itch.io</a>, Newgrounds, Cinevva, situs Anda sendiri) menyematkan game dalam iframe. Tutorial ini membahas cara membuat game Anda berfungsi dengan baik saat disematkan.</p>
<h2>1) Penyiapan dasar agar dapat disematkan</h2>
<p>Game Anda harus dapat berfungsi tanpa memerlukan kendali penuh atas halaman:</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) Mendeteksi konteks iframe</h2>
<pre><code class="language-js">const isEmbedded = window.self !== window.top

if (isEmbedded) {
  // Sesuaikan perilaku untuk konteks penyematan
  hideExternalLinks()
  adjustUIForSmallSize()
}

function hideExternalLinks() {
  document.querySelectorAll('a[target=&quot;_blank&quot;]').forEach(link =&gt; {
    link.style.display = 'none'
  })
}
</code></pre>
<h2>3) Mengizinkan izin iframe yang diperlukan</h2>
<p>Halaman induk mengendalikan apa yang dapat dilakukan iframe Anda. Izin yang umum digunakan:</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>Game Anda harus menangani izin yang tidak tersedia dengan baik:</p>
<pre><code class="language-js">// Periksa apakah mode layar penuh tersedia
const canFullscreen = document.fullscreenEnabled || document.webkitFullscreenEnabled

if (!canFullscreen) {
  document.getElementById('fullscreen-btn').style.display = 'none'
}
</code></pre>
<h2>4) Mode layar penuh dari iframe</h2>
<p>Mode layar penuh memerlukan atribut <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) {
    // Mode layar penuh tidak diizinkan—tampilkan pesan
    showMessage('Mode layar penuh tidak tersedia saat disematkan')
  }
}
</code></pre>
<h2>5) Berkomunikasi dengan halaman induk</h2>
<p>Gunakan <code>postMessage</code> untuk komunikasi lintas origin yang aman:</p>
<p><strong>Dalam game Anda:</strong></p>
<pre><code class="language-js">// Kirim pesan ke induk
function notifyParent(type, data) {
  if (window.parent !== window) {
    window.parent.postMessage({ type, data, source: 'game' }, '*')
  }
}

// Terima pesan dari induk
window.addEventListener('message', (event) =&gt; {
  // Validasi origin jika diperlukan
  // 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
  }
})

// Beri tahu saat game siap
window.addEventListener('load', () =&gt; {
  notifyParent('ready', { width: 800, height: 600 })
})

// Beri tahu saat terjadi peristiwa dalam game
function onGameOver(score) {
  notifyParent('gameover', { score })
}
</code></pre>
<p><strong>Dalam halaman induk:</strong></p>
<pre><code class="language-js">const iframe = document.getElementById('game-iframe')

iframe.addEventListener('load', () =&gt; {
  // Dengarkan pesan dari 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 siap:', data)
    }
    if (type === 'gameover') {
      showScoreModal(data.score)
    }
  })
})

// Kirim perintah ke game
function pauseGame() {
  iframe.contentWindow.postMessage({ type: 'pause' }, '*')
}
</code></pre>
<h2>6) Menangani fokus</h2>
<p>Iframe dapat kehilangan fokus sehingga input keyboard tidak berfungsi:</p>
<pre><code class="language-js">// Fokuskan canvas secara otomatis saat diklik
canvas.addEventListener('click', () =&gt; {
  canvas.focus()
})

// Jadikan canvas dapat menerima fokus
canvas.tabIndex = 1

// Tangani hilangnya fokus
window.addEventListener('blur', () =&gt; {
  // Atur ulang tombol yang sedang ditekan
  input.left = input.right = input.up = input.down = false
  
  if (isEmbedded) {
    // Jeda jika diinginkan
    // pauseGame()
  }
})

// Minta fokus dari induk
function requestFocus() {
  notifyParent('requestFocus', {})
}
</code></pre>
<h2>7) Ukuran penyematan yang responsif</h2>
<p>Tangani berbagai dimensi penyematan:</p>
<pre><code class="language-js">function handleResize() {
  const width = window.innerWidth
  const height = window.innerHeight
  
  // Sesuaikan UI berdasarkan ukuran
  if (width &lt; 400 || height &lt; 300) {
    enableCompactUI()
  } else {
    enableFullUI()
  }
  
  // Skalakan game dengan tepat
  resizeCanvas(width, height)
}

window.addEventListener('resize', handleResize)
handleResize()
</code></pre>
<h2>8) Indikator pemuatan untuk penyematan</h2>
<p>Tampilkan sesuatu dengan segera:</p>
<pre><code class="language-js">// Sisipkan langsung dalam HTML agar segera ditampilkan
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;Memuat...&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
`

// Hapus saat game siap
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 khusus platform</h2>
<p>Beberapa platform memiliki API sendiri:</p>
<p><strong><a href="http://Itch.io">Itch.io</a>:</strong></p>
<pre><code class="language-js">// Tidak memerlukan SDK, tetapi postMessage dapat digunakan untuk pencapaian
window.parent.postMessage({ type: 'itch-achievement', data: { id: 'first-win' }}, '*')
</code></pre>
<p><strong>Newgrounds:</strong></p>
<pre><code class="language-js">// Sertakan SDK Newgrounds.io
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">// Beri sinyal bahwa game sudah siap
window.parent.postMessage({ type: 'cinevva:ready' }, '*')

// Beri sinyal bahwa game sudah dapat dimainkan
window.parent.postMessage({ type: 'cinevva:playable' }, '*')
</code></pre>
<h2>10) Pertimbangan keamanan</h2>
<pre><code class="language-js">// Validasi origin pesan untuk operasi sensitif
window.addEventListener('message', (event) =&gt; {
  const trustedOrigins = [
    'https://itch.io',
    'https://cinevva.com',
    'https://yoursite.com'
  ]
  
  if (!trustedOrigins.includes(event.origin)) {
    return // Abaikan pesan yang tidak tepercaya
  }
  
  // Proses pesan...
})

// Jangan mengekspos operasi sensitif melalui postMessage
// Hanya izinkan perintah yang masuk daftar izin
const allowedCommands = ['pause', 'resume', 'setVolume', 'mute']

window.addEventListener('message', (event) =&gt; {
  const { type } = event.data
  if (!allowedCommands.includes(type)) return
  
  // Tangani perintah...
})
</code></pre>
<h2>Daftar periksa pengujian</h2>
<ul>
<li><strong>Pengujian lokal</strong>: Gunakan server HTTP sederhana, bukan <code>file://</code></li>
<li><strong>Lintas origin</strong>: Uji dengan penyematan iframe yang sebenarnya</li>
<li><strong>Izin</strong>: Uji dengan sandbox yang dibatasi</li>
<li><strong>Fokus</strong>: Uji keyboard setelah mengeklik di luar iframe</li>
<li><strong>Pengubahan ukuran</strong>: Uji pada berbagai ukuran penyematan</li>
<li><strong>Perangkat seluler</strong>: Uji input sentuh dalam konteks penyematan</li>
</ul>
<pre><code class="language-html">&lt;!-- Halaman pengujian penyematan --&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;Pengujian Penyematan&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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/mobile-friendly-web-games">Game web yang ramah perangkat seluler</a></li>
<li><a href="/id/creators">Untuk kreator</a></li>
<li><a href="/id/tutorials/coop-coep-sharedarraybuffer">COOP/COEP dan SharedArrayBuffer</a> — header lintas origin yang memengaruhi penyematan iframe</li>
<li><a href="/id/guides/itch-io-launch-guide">Cara Meluncurkan Game Anda di itch.io</a> — <a href="http://itch.io">itch.io</a> menyematkan game browser secara bawaan dalam iframe</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe">MDN: elemen iframe</a> — referensi lengkap untuk atribut iframe</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy">MDN: Permissions Policy</a> — mengendalikan fitur yang dapat digunakan iframe</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage">MDN: API postMessage</a> — berkomunikasi antara iframe dan induk</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Menyimpan dan memuat status game dengan IndexedDB]]></title>
            <link>https://app.cinevva.com/id/tutorials/indexeddb-game-saves</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/indexeddb-game-saves</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Penyimpanan game persisten dengan IndexedDB: menyimpan progres, pengaturan, dan data berukuran besar. Pola asinkron yang tidak akan memblokir game Anda.]]></description>
            <content:encoded><![CDATA[<h1>Menyimpan dan memuat status game dengan IndexedDB</h1>
<p>IndexedDB adalah pilihan terbaik untuk menyimpan data game secara persisten di browser. IndexedDB dapat menangani data dalam jumlah besar, bekerja secara offline, dan tidak memblokir thread utama.</p>
<h2>1) Mengapa memilih IndexedDB daripada localStorage?</h2>
<table>
<thead>
<tr>
<th>Fitur</th>
<th>localStorage</th>
<th>IndexedDB</th>
</tr>
</thead>
<tbody>
<tr>
<td>Batas penyimpanan</td>
<td>~5–10 MB</td>
<td>50+ MB (sering kali GB)</td>
</tr>
<tr>
<td>Jenis data</td>
<td>Hanya string</td>
<td>Objek, blob, array</td>
</tr>
<tr>
<td>Asinkron</td>
<td>Tidak (memblokir)</td>
<td>Ya</td>
</tr>
<tr>
<td>Kueri terindeks</td>
<td>Tidak</td>
<td>Ya</td>
</tr>
</tbody>
</table>
<p>Untuk game, IndexedDB hampir selalu menjadi pilihan yang tepat.</p>
<h2>2) Membuka database</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) Menyimpan status game</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) Memuat status game</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) Menampilkan semua penyimpanan</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) Menghapus penyimpanan</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) Class GameStorage yang lengkap</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) Menyimpan data biner (tekstur, audio)</h2>
<p>IndexedDB dapat menangani Blob dan ArrayBuffer:</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) Pola simpan otomatis</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) Penanganan error dan fallback</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) Mempertahankan penyimpanan agar tidak terhapus</h2>
<p>IndexedDB tidak dijamin akan selalu tersimpan. Secara default, sebuah origin menggunakan penyimpanan &quot;best-effort&quot;, dan browser dapat menghapusnya saat ruang disk penuh atau, pada Safari/WebKit, setelah situs Anda tidak berinteraksi dengan pengguna selama jangka waktu tertentu. Untuk penyimpanan game, data semacam inilah yang tentu tidak ingin Anda hilangkan.</p>
<p>Minta penyimpanan persisten agar browser tidak menghapus data Anda tanpa tindakan eksplisit dari pengguna:</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>Browser menentukan apakah permintaan tersebut dikabulkan berdasarkan sinyal interaksi (seberapa sering pengguna berinteraksi dengan situs Anda, apakah situs dipasang sebagai PWA), jadi jangan berasumsi bahwa permintaan ini selalu berhasil. Anda juga dapat memeriksa ruang yang tersedia sebelum menulis penyimpanan berukuran besar atau aset yang di-cache:</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>Kedua API tersebut memerlukan konteks aman (HTTPS atau localhost).</p>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/pwa-offline-games">PWA untuk game offline</a></li>
<li><a href="/id/tutorials/service-workers-caching">Service worker untuk caching game</a></li>
<li><a href="/id/tutorials/ship-web-game-fast">Merilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/streaming-asset-loading">Pemuatan aset secara streaming</a> — menggunakan IndexedDB sebagai cache aset</li>
<li><a href="/id/tutorials/analytics-web-games">Analitik untuk game web</a> — melacak pola penyimpanan/pemuatan untuk memahami perilaku pemain</li>
</ul>
<h2>Sumber Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">MDN: API IndexedDB</a> — referensi API lengkap</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB">MDN: Menggunakan IndexedDB</a> — panduan langkah demi langkah</li>
<li><a href="https://github.com/jakearchibald/idb">Library idb</a> — wrapper IndexedDB kecil berbasis Promise</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Game web yang ramah perangkat seluler]]></title>
            <link>https://app.cinevva.com/id/tutorials/mobile-friendly-web-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/mobile-friendly-web-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Buat game web berfungsi dengan baik di perangkat seluler: kontrol sentuh, performa, viewport, baterai, dan desain responsif. Jangkau platform game terbesar.]]></description>
            <content:encoded><![CDATA[<h1>Game web yang ramah perangkat seluler</h1>
<p>Perangkat seluler adalah platform game terbesar. Beberapa penyesuaian dapat membuat game web Anda berfungsi dengan sangat baik di ponsel dan tablet.</p>
<h2>1) Penyiapan viewport</h2>
<p>Cegah zoom dan pastikan penskalaan yang tepat:</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 layar penuh</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>Jika Anda menentukan ukuran UI berdasarkan tinggi viewport, bukan <code>height: 100%</code>, gunakan unit viewport dinamis <code>dvh</code>, <code>svh</code>, dan <code>lvh</code> daripada <code>vh</code>. Unit ini memperhitungkan bilah alat browser seluler yang dapat menyusut dan mengembang, sedangkan <code>100vh</code> mengabaikannya (di iOS, <code>100vh</code> adalah tinggi saat bilah alat tersembunyi, sehingga bagian bawah tata letak Anda terpotong). Unit-unit ini telah mencapai status Baseline Widely Available dan didukung di Chrome/Edge 108+, Firefox 101+, serta Safari/iOS Safari 15.4+, atau sekitar 92% pengguna.</p>
<h2>3) Penanganan peristiwa sentuh</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>Jika game Anda juga perlu berfungsi dengan mouse atau stilus, pertimbangkan Pointer Events (<code>pointerdown</code>/<code>pointermove</code>/<code>pointerup</code>) sebagai pengganti peristiwa sentuh. MDN kini merekomendasikannya sebagai satu model input yang tidak bergantung pada perangkat: satu alur kode menangani sentuhan, mouse, dan pena, sementara multisentuh tetap berfungsi melalui <code>pointerId</code> (padanan <code>touch.identifier</code> yang digunakan di atas). Peristiwa sentuh tetap sangat cocok untuk game yang hanya menggunakan sentuhan.</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) Tombol sentuh</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) Penanganan orientasi</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) Optimalisasi performa</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) Pertimbangan baterai</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) Fullscreen API</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>Satu hal yang perlu diperhatikan: Fullscreen API tidak berfungsi untuk elemen umum di iPhone. Safari iOS hanya menyediakan mode layar penuh untuk elemen video native (melalui <code>webkitEnterFullscreen</code> yang nonstandar), sehingga <code>documentElement.requestFullscreen()</code> akan ditolak di iPhone. Blok <code>try/catch</code> di atas menanganinya dengan baik. Karena itu, rancang game Anda agar tetap dapat dimainkan tanpa layar penuh sebenarnya di iPhone, dan perlakukan layar penuh sebagai peningkatan di iPad, Android, serta desktop yang mendukungnya. Pemanggilan <code>screen.orientation.lock()</code> memiliki keterbatasan yang sama di iPhone, jadi jangan bergantung padanya.</p>
<h2>10) Daftar periksa pengujian</h2>
<p>Sebelum merilis:</p>
<ul>
<li><strong>Respons sentuhan</strong> — Uji di perangkat nyata, bukan hanya emulator</li>
<li><strong>Berbagai ukuran layar</strong> — Ponsel, tablet, iPad Pro</li>
<li><strong>Kedua orientasi</strong> — Atau kunci ke salah satunya</li>
<li><strong>Perangkat kelas bawah</strong> — Uji di ponsel berusia 2–3 tahun</li>
<li><strong>Jaringan lambat</strong> — Uji dengan simulasi 3G</li>
<li><strong>Safari iOS</strong> — Memiliki keunikan terkait audio dan layar penuh</li>
<li><strong>Chrome Android</strong> — Browser yang paling umum</li>
<li><strong>Gangguan</strong> — Panggilan telepon, notifikasi</li>
<li><strong>Tekanan memori</strong> — Apakah game pulih setelah tab berada di latar belakang?</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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/game-input-handling">Penanganan input game</a></li>
<li><a href="/id/tutorials/responsive-game-canvas">Canvas game responsif</a></li>
<li><a href="/id/tutorials/pwa-offline-games">PWA untuk game offline</a></li>
<li><a href="/id/tutorials/web-audio-api-games">Web Audio API untuk game</a> — menangani pembatasan audio iOS</li>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a> — mengoptimalkan waktu pemuatan untuk koneksi seluler</li>
<li><a href="/id/tutorials/iframe-embedding-games">Menyematkan game dengan iframe</a> — pertimbangan penyematan khusus perangkat seluler</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Touch_events">MDN: Peristiwa sentuh</a> — penanganan multisentuh</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API">MDN: Screen Orientation API</a> — mengunci orientasi</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">MDN: Fullscreen API</a> — memasuki mode layar penuh di perangkat seluler</li>
<li><a href="https://web.dev/learn/design/">web.dev: Desain responsif</a> — pola responsif yang berlaku untuk UI game</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Rendering pixel art dalam game web]]></title>
            <link>https://app.cinevva.com/id/tutorials/pixel-art-rendering</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/pixel-art-rendering</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Render pixel art dengan benar: penskalaan tajam, tanpa buram, palet warna, dan animasi. Buat game retro Anda terlihat dirancang dengan sengaja.]]></description>
            <content:encoded><![CDATA[<h1>Rendering pixel art dalam game web</h1>
<p>Pixel art terlihat menakjubkan saat dirender dengan benar—dan buruk saat buram. Tutorial ini membahas teknik untuk menghasilkan grafis piksel yang tajam dan autentik.</p>
<h2>1) Masalah keburaman</h2>
<p>Secara default, browser menghaluskan gambar saat melakukan penskalaan. Untuk pixel art, ini tidak tepat:</p>
<pre><code class="language-css">/* CSS solution */
canvas {
  image-rendering: pixelated;
  image-rendering: crisp-edges; /* Firefox fallback */
}
</code></pre>
<h2>2) Pengaturan konteks 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>Pengaturan ini harus diterapkan kembali setelah setiap perubahan ukuran canvas atau perubahan konteks.</p>
<h2>3) Penskalaan bilangan bulat</h2>
<p>Pixel art terlihat paling baik pada skala bilangan bulat (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) Menggambar sprite pada posisi bilangan bulat</h2>
<p>Posisi subpiksel menyebabkan gambar buram:</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) Animasi sprite sheet</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) Pertukaran palet</h2>
<p>Teknik klasik untuk mewarnai ulang sprite:</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) Rendering tilemap</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) Efek garis tepi</h2>
<p>Tambahkan garis tepi pada sprite agar lebih mudah terlihat:</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) Guncangan layar</h2>
<p>Efek klasik untuk meningkatkan sensasi permainan:</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) Batasan palet warna</h2>
<p>Pixel art autentik menggunakan palet terbatas:</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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/canvas-2d-game-loop">Game loop Canvas 2D</a></li>
<li><a href="/id/tutorials/responsive-game-canvas">Canvas game responsif</a></li>
<li><a href="/id/tutorials/streaming-asset-loading">Pemuatan aset secara streaming</a></li>
<li><a href="/id/guides/game-assets-guide">Tempat Menemukan Aset Game Gratis</a> — sprite sheet, paket pixel art, dan sumber aset 2D</li>
<li><a href="/id/tutorials/text-rendering-games">Rendering teks dalam game</a> — font bitmap yang sesuai dengan gaya pixel art Anda</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pointer Lock untuk game FPS]]></title>
            <link>https://app.cinevva.com/id/tutorials/pointer-lock-fps</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/pointer-lock-fps</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Implementasikan kendali pandangan dengan mouse menggunakan Pointer Lock API: menangkap kursor, menangani pergerakan, dan membuat kontrol FPS untuk game web.]]></description>
            <content:encoded><![CDATA[<h1>Pointer Lock untuk game FPS</h1>
<p>Pointer Lock API menangkap kursor mouse sehingga memungkinkan kontrol kamera bergaya FPS yang mulus. Fitur ini penting untuk game tembak-menembak orang pertama dan orang ketiga.</p>
<h2>1) Meminta pointer lock</h2>
<p>Pointer lock memerlukan gestur pengguna:</p>
<pre><code class="language-js">const canvas = document.getElementById('game')

canvas.addEventListener('click', () =&gt; {
  canvas.requestPointerLock()
})
</code></pre>
<h2>Input mouse mentah (menonaktifkan akselerasi)</h2>
<p>Secara default, browser menerapkan akselerasi mouse dari sistem operasi pada <code>movementX</code>/<code>movementY</code>, sehingga gerakan cepat dan seretan lambat pada jarak yang sama menghasilkan rotasi kamera yang berbeda. Untuk membidik dalam FPS, biasanya Anda menginginkan kebalikannya: jarak fisik yang sama harus selalu memutar kamera dalam jumlah yang sama. Minta pergerakan mentah tanpa akselerasi dengan meneruskan <code>unadjustedMovement: true</code> ke <code>requestPointerLock()</code>:</p>
<pre><code class="language-js">canvas.addEventListener('click', async () =&gt; {
  try {
    await canvas.requestPointerLock({ unadjustedMovement: true })
  } catch (err) {
    // unadjustedMovement tidak didukung di sini; gunakan delta yang disesuaikan OS
    await canvas.requestPointerLock()
  }
})
</code></pre>
<p><code>requestPointerLock()</code> modern mengembalikan Promise yang terselesaikan saat berhasil dan ditolak saat gagal. Karena itu, pemanggilan di atas dibungkus dalam <code>try/catch</code>. Chrome dan Edge telah mendukung <code>unadjustedMovement</code> sejak versi 88, sedangkan Safari mendukungnya sejak versi 18.4 (Maret 2025), yang juga merupakan rilis yang memperbaiki <code>requestPointerLock</code> agar mengembalikan Promise. Browser lama yang tidak mengembalikan Promise tetap dapat bekerja di sini karena <code>await</code> menerima nilai non-Promise dan opsi yang tidak dikenal akan diabaikan begitu saja. Karena itu, selalu pertahankan <code>requestPointerLock()</code> biasa sebagai fallback.</p>
<h2>2) Mendeteksi status penguncian</h2>
<pre><code class="language-js">document.addEventListener('pointerlockchange', () =&gt; {
  if (document.pointerLockElement === canvas) {
    console.log('Pointer terkunci')
    game.mouseLocked = true
  } else {
    console.log('Pointer tidak terkunci')
    game.mouseLocked = false
  }
})

document.addEventListener('pointerlockerror', () =&gt; {
  console.error('Pointer lock gagal')
})
</code></pre>
<h2>3) Membaca pergerakan mouse</h2>
<p>Saat terkunci, gunakan <code>movementX</code> dan <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
  
  // Batasi pitch agar kamera tidak terbalik
  camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})
</code></pre>
<h2>4) Class kamera FPS</h2>
<pre><code class="language-js">class FPSCamera {
  constructor() {
    this.position = { x: 0, y: 1.7, z: 0 } // Tinggi mata
    this.yaw = 0      // Rotasi kiri/kanan
    this.pitch = 0    // Rotasi atas/bawah
    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()
    
    // Bergerak hanya pada bidang 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) Pergerakan 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) Pengontrol FPS lengkap</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)
    
    // Pergerakan 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
    
    // Melompat
    if (this.input.jump &amp;&amp; this.onGround) {
      this.velocity.y = this.jumpSpeed
      this.onGround = false
    }
    
    // Gravitasi
    if (!this.onGround) {
      this.velocity.y += this.gravity * dt
    }
    
    // Terapkan kecepatan
    this.camera.position.x += this.velocity.x * dt
    this.camera.position.y += this.velocity.y * dt
    this.camera.position.z += this.velocity.z * dt
    
    // Tabrakan tanah sederhana
    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) Crosshair</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
  
  // Atas
  ctx.beginPath()
  ctx.moveTo(cx, cy - gap)
  ctx.lineTo(cx, cy - gap - size)
  ctx.stroke()
  
  // Bawah
  ctx.beginPath()
  ctx.moveTo(cx, cy + gap)
  ctx.lineTo(cx, cy + gap + size)
  ctx.stroke()
  
  // Kiri
  ctx.beginPath()
  ctx.moveTo(cx - gap, cy)
  ctx.lineTo(cx - gap - size, cy)
  ctx.stroke()
  
  // Kanan
  ctx.beginPath()
  ctx.moveTo(cx + gap, cy)
  ctx.lineTo(cx + gap + size, cy)
  ctx.stroke()
}
</code></pre>
<h2>8) Menembak dengan tombol mouse</h2>
<pre><code class="language-js">document.addEventListener('mousedown', (e) =&gt; {
  if (!document.pointerLockElement) return
  
  if (e.button === 0) {
    // Klik kiri - tembakan utama
    weapon.fire()
  } else if (e.button === 2) {
    // Klik kanan - membidik melalui alat bidik
    weapon.aimDownSights(true)
  }
})

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

// Cegah menu konteks
canvas.addEventListener('contextmenu', (e) =&gt; e.preventDefault())
</code></pre>
<h2>9) Pengaturan sensitivitas</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())
  }
}

// Terapkan di handler mouse
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) Petunjuk keluar</h2>
<p>Tampilkan petunjuk untuk keluar dari pointer lock:</p>
<pre><code class="language-js">function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '&lt;p&gt;Tekan ESC untuk melepaskan kunci mouse&lt;/p&gt;'
    ui.style.opacity = '0.5'
    setTimeout(() =&gt; ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '&lt;p&gt;Klik untuk bermain&lt;/p&gt;'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)
</code></pre>
<p>Satu hal yang perlu diketahui tentang petunjuk keluar: ketika pemain menekan ESC untuk keluar dari pointer lock, browser memerlukan gestur pengguna baru (sebuah klik) sebelum Anda dapat menguncinya kembali. Itulah alasan overlay &quot;Klik untuk bermain&quot; diperlukan. Namun, jika kode Anda sendiri memanggil <code>document.exitPointerLock()</code> (misalnya untuk membuka menu dalam game), tidak diperlukan gestur baru untuk mengunci kembali setelahnya. Jadi, Anda dapat mengaktifkannya kembali secara terprogram saat menu ditutup. Menekan ESC berulang kali juga dapat memberi sinyal kepada browser untuk menolak penguncian kembali sampai pengguna melakukan tindakan yang lebih disengaja. Karena itu, jangan mencoba mengunci ulang secara otomatis saat <code>pointerlockchange</code>.</p>
<h2>Pertimbangan untuk iframe</h2>
<p>Pointer lock di dalam iframe memerlukan atribut <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>Periksa apakah pointer lock tersedia:</p>
<pre><code class="language-js">if (!document.pointerLockElement &amp;&amp; !('requestPointerLock' in canvas)) {
  showMessage('Penangkapan mouse tidak tersedia')
}
</code></pre>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/game-input-handling">Penanganan input game</a></li>
<li><a href="/id/tutorials/gamepad-api">Gamepad API</a></li>
<li><a href="/id/tutorials/webgl-fundamentals-for-games">Dasar-dasar WebGL</a></li>
<li><a href="/id/tutorials/webxr-basics">Dasar-dasar WebXR</a> — kontrol VR untuk pengalaman orang pertama</li>
<li><a href="/id/tutorials/webgpu-getting-started">Memulai dengan WebGPU</a> — rendering GPU modern untuk game FPS</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API">MDN: Pointer Lock API</a> — referensi API lengkap</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX">MDN: MouseEvent.movementX</a> — membaca delta mouse</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[PWA untuk game web offline]]></title>
            <link>https://app.cinevva.com/id/tutorials/pwa-offline-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/pwa-offline-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Buat game Anda berfungsi secara offline dengan PWA: manifes, service worker, strategi caching, dan prompt penginstalan. Mainkan di mana saja, kapan saja.]]></description>
            <content:encoded><![CDATA[<h1>PWA untuk game web offline</h1>
<p>Progressive Web App memungkinkan pemain menginstal game Anda dan memainkannya secara offline. Tutorial ini menunjukkan cara menambahkan dukungan PWA ke game web.</p>
<h2>1) Manifes Aplikasi Web</h2>
<p>Buat <code>manifest.json</code>:</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;Game Keren Saya&quot;,
  &quot;short_name&quot;: &quot;GameKeren&quot;,
  &quot;description&quot;: &quot;Game keren yang dapat Anda mainkan secara offline&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>Tautkan manifes tersebut di HTML Anda:</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 dasar</h2>
<p>Buat <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) Daftarkan 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) Cache-first dengan pembaruan jaringan</h2>
<p>Lebih baik untuk game—pemuatan cepat dengan pembaruan di latar belakang:</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) Cache berversi untuk pembaruan</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) Menangani pembaruan game</h2>
<p>Beri tahu pemain saat pembaruan tersedia:</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;Game telah diperbarui! Muat ulang untuk mendapatkan versi terbaru.&lt;/p&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Muat ulang&lt;/button&gt;
  `
  banner.className = 'update-banner'
  document.body.appendChild(banner)
}
</code></pre>
<h2>7) Deteksi offline</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) Prompt penginstalan</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>Di iOS dan iPadOS, peristiwa <code>beforeinstallprompt</code> tidak pernah dipicu, sehingga tombol instal di atas hanya muncul di browser Chromium. Pengguna iPhone dan iPad dapat menginstal dengan mengetuk Bagikan, lalu &quot;Tambahkan ke Layar Utama&quot;. Ada satu perubahan pada tahun 2026 yang perlu diketahui: mulai Safari 26 (iOS 26 / iPadOS 26), setiap situs yang ditambahkan ke Layar Utama akan terbuka sebagai aplikasi web secara default, dengan tombol alih &quot;Buka sebagai Aplikasi Web&quot; yang aktif secara default. Kini tidak ada lagi persyaratan agar situs dapat diinstal di iOS, meskipun menyediakan manifes dan service worker tetap memberikan pengalaman offline yang jauh lebih baik.</p>
<h2>9) Sinkronisasi latar belakang untuk papan peringkat</h2>
<p>Perhatikan dukungannya: Background Sync API hanya diimplementasikan di browser Chromium (Chrome, Edge, Opera, Samsung Internet). Firefox dan Safari (termasuk iOS) tidak mendukungnya, jadi selalu lakukan deteksi fitur dan sediakan fallback yang mengirimkan skor saat aplikasi dimuat secara normal berikutnya.</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) Menguji 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; Kotak centang Offline</li>
</ul>
<p><strong>Audit Lighthouse:</strong></p>
<ul>
<li>Jalankan audit PWA</li>
<li>Periksa apakah aplikasi dapat diinstal</li>
<li>Periksa kemampuan offline</li>
</ul>
<p><strong>Pengujian di perangkat nyata:</strong></p>
<ul>
<li>Instal di layar utama ponsel</li>
<li>Aktifkan mode pesawat</li>
<li>Uji semua fitur secara offline</li>
</ul>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/service-workers-caching">Service worker untuk caching game</a></li>
<li><a href="/id/tutorials/indexeddb-game-saves">Penyimpanan game dengan IndexedDB</a></li>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/mobile-friendly-web-games">Game web yang ramah perangkat seluler</a> — kontrol sentuh dan penanganan viewport untuk game PWA</li>
<li><a href="/id/tutorials/streaming-asset-loading">Pemuatan aset secara streaming</a> — strategi pemuatan yang bekerja dengan caching service worker</li>
</ul>
<h2>Referensi Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps">MDN: Progressive Web App</a> — dokumentasi lengkap PWA</li>
<li><a href="https://web.dev/learn/pwa/">web.dev: Pelajari PWA</a> — jalur pembelajaran PWA dari Google</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Manifest">MDN: Manifes Aplikasi Web</a> — referensi file manifes</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Canvas game responsif yang diskalakan dengan benar]]></title>
            <link>https://app.cinevva.com/id/tutorials/responsive-game-canvas</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/responsive-game-canvas</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Buat canvas game Anda responsif: penskalaan piksel sempurna, letterboxing, dan penanganan peristiwa perubahan ukuran tanpa tersendat.]]></description>
            <content:encoded><![CDATA[<h1>Canvas Game Responsif</h1>
<p>Canvas yang tidak diskalakan dengan benar akan terlihat buram atau rusak pada ukuran layar yang berbeda. Saya sudah menangani masalah ini berkali-kali, jadi biarkan saya membantu Anda menghindari kerepotan tersebut.</p>
<p><strong>Coba berbagai mode penskalaan:</strong></p>
<ResponsiveDemo />
<h2>Masalah Utama</h2>
<p>Canvas memiliki dua ukuran yang mudah tertukar. Ukuran CSS mengatur seberapa besar tampilannya di layar. Resolusi internal mengatur berapa banyak piksel yang benar-benar Anda gambar. Jika keduanya tidak cocok, hasilnya akan buram atau terdistorsi.</p>
<p>Pada layar Retina, CSS Anda mungkin menentukan ukuran 800x600, tetapi layar tersebut sebenarnya memiliki 1600x1200 piksel fisik. Jika resolusi internal canvas hanya 800x600, browser akan memperbesarnya sehingga semuanya terlihat buram.</p>
<h2>Penyiapan Responsif Dasar</h2>
<p>Berikut cara membuat canvas memenuhi jendela dan tetap terlihat tajam:</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
  
  // Atur resolusi internal agar sesuai dengan piksel fisik
  canvas.width = width * dpr
  canvas.height = height * dpr
  
  // Atur ukuran CSS agar sesuai dengan piksel logis
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  
  // Skalakan konteks agar Anda dapat menggambar dalam piksel logis
  ctx.scale(dpr, dpr)
}

window.addEventListener('resize', resize)
resize()
</code></pre>
<p>Baris <code>ctx.scale(dpr, dpr)</code> adalah kuncinya. Setelah itu, Anda dapat menggambar pada posisi (100, 100) dan hasilnya akan muncul di tempat yang tepat, berapa pun DPR-nya. Secara internal, canvas dirender pada ukuran 200x200 di layar 2x, tetapi kode Anda tidak perlu mengetahuinya.</p>
<p>Satu hal yang perlu diperhatikan: <code>ctx.scale</code> bersifat kumulatif. Jika Anda memanggil fungsi resize beberapa kali, skalanya akan terus bertambah. Atur ulang transformasi terlebih dahulu dengan <code>ctx.setTransform(1, 0, 0, 1, 0, 0)</code> sebelum melakukan penskalaan, atau lakukan penskalaan hanya sekali.</p>
<h2>Rasio Aspek Tetap dengan Letterboxing</h2>
<p>Banyak game membutuhkan rasio aspek tertentu. Anda tentu tidak ingin game 16:9 direntangkan untuk memenuhi monitor ultrawide 21:9. Letterboxing menambahkan bilah hitam untuk mempertahankan rasio tersebut.</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) {
    // Jendela lebih lebar daripada game, tambahkan bilah di sisi-sisinya
    height = windowHeight
    width = height * ASPECT
  } else {
    // Jendela lebih tinggi daripada game, tambahkan bilah di atas/bawah
    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'
  
  // Resolusi internal tetap
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
}
</code></pre>
<p>Agar bilah letterbox terlihat, atur latar belakang body menjadi hitam:</p>
<pre><code class="language-css">body {
  margin: 0;
  background: #000;
  overflow: hidden;
}
</code></pre>
<h2>Penskalaan Bilangan Bulat untuk Seni Piksel</h2>
<p>Seni piksel terlihat tajam pada skala bilangan bulat (1x, 2x, 3x) dan buram pada skala pecahan seperti 2,7x. Bahkan dengan <code>image-rendering: pixelated</code>, skala 2,5x berarti sebagian piksel memiliki lebar 2 piksel layar dan sebagian lainnya 3 piksel.</p>
<pre><code class="language-js">const GAME_WIDTH = 320
const GAME_HEIGHT = 180

function resizePixelPerfect() {
  const maxWidth = window.innerWidth
  const maxHeight = window.innerHeight
  
  // Cari skala bilangan bulat terbesar yang muat
  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>Konsekuensinya, game Anda tidak akan memenuhi layar secara persis. Pada layar 1920x1080 dengan game berukuran 320x180, Anda mendapatkan penskalaan 5x (1600x900) dengan bilah hitam di sekeliling tepinya. Sebagian pemain lebih menyukai ini; pemain lain ingin game memenuhi layar meskipun hasilnya sedikit buram.</p>
<h2>Penskalaan Canvas WebGL</h2>
<p>Untuk WebGL, Anda perlu memperbarui viewport selain ukuran canvas:</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 // Ukuran berubah, Anda mungkin perlu memperbarui matriks proyeksi
  }
  return false
}
</code></pre>
<p>Panggil fungsi ini pada awal loop render Anda, bukan hanya saat peristiwa perubahan ukuran terjadi. Beberapa browser mengubah DPR ketika Anda menyeret jendela dari satu monitor ke monitor lain, dan perubahan tersebut perlu terdeteksi.</p>
<h2>Debouncing Peristiwa Perubahan Ukuran</h2>
<p>Peristiwa perubahan ukuran dipicu dengan cepat ketika pengguna menyeret tepi jendela. Jika fungsi resize Anda berat, tampilan akan tersendat.</p>
<pre><code class="language-js">let resizeTimeout
window.addEventListener('resize', () =&gt; {
  clearTimeout(resizeTimeout)
  resizeTimeout = setTimeout(resize, 100)
})
</code></pre>
<p>Jeda 100 milidetik berarti perubahan ukuran hanya dilakukan setelah pengguna berhenti menyeret. Agar penanganan lebih mulus selama proses menyeret, gunakan ResizeObserver:</p>
<pre><code class="language-js">const observer = new ResizeObserver(entries =&gt; {
  resize()
})
observer.observe(document.body)
</code></pre>
<p>ResizeObserver dipicu lebih jarang dan dioptimalkan dengan lebih baik oleh browser.</p>
<h2>Layar Penuh</h2>
<p>Fullscreen API memungkinkan game mengambil alih seluruh layar:</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>Anda harus memanggil <code>requestFullscreen</code> sebagai respons terhadap gestur pengguna seperti klik. Jika tidak, browser akan memblokirnya. Jangan lupa mengubah ukuran saat masuk atau keluar dari mode layar penuh karena ruang yang tersedia berubah.</p>
<p>Satu keterbatasan platform: hingga pertengahan 2026, Fullscreen API masih belum mendukung layar penuh untuk elemen di Safari pada iPhone. requestFullscreen berfungsi di browser desktop dan iPadOS, tetapi di iPhone fitur ini tidak didukung untuk elemen sembarang seperti canvas (hanya layar penuh video native yang berfungsi). Deteksi dukungan fitur dengan <code>if (canvas.requestFullscreen)</code> dan sediakan alternatif yang baik, misalnya dengan mengatur ukuran canvas agar memenuhi viewport yang terlihat atau meminta pengguna menambahkan game ke layar utama sebagai PWA.</p>
<h2>Menggabungkan Semuanya</h2>
<p>Berikut adalah class yang menangani semua kasus umum:</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>Gunakan seperti ini:</p>
<pre><code class="language-js">const responsive = new ResponsiveCanvas(document.getElementById('game'), {
  width: 320,
  height: 180,
  pixelArt: true,
  letterbox: true
})

// Dalam kode game Anda, gunakan responsive.width dan responsive.height
</code></pre>
<h2>Kendala Umum</h2>
<p>Beberapa hal yang sering mengecoh orang:</p>
<p><strong>Canvas dikosongkan saat ukurannya berubah.</strong> Ketika Anda mengatur <code>canvas.width</code> atau <code>canvas.height</code>, canvas akan dikosongkan. Jika Anda tidak menggambar ulang pada setiap frame, Anda perlu menggambar ulang setelah ukurannya berubah.</p>
<p><strong>DPR dapat berubah.</strong> Jika pengguna menyeret jendela browser dari layar Retina ke layar non-Retina, DPR akan berubah. Periksa nilainya di dalam loop render, bukan hanya saat terjadi perubahan ukuran.</p>
<p><strong>Keunikan viewport Safari di iOS.</strong> Tinggi viewport berubah ketika bilah alamat ditampilkan atau disembunyikan. Gunakan <code>window.visualViewport</code> untuk menentukan ukuran secara lebih akurat di perangkat seluler.</p>
<p><strong>State konteks diatur ulang.</strong> Mengatur dimensi canvas akan mengatur ulang state konteks, termasuk transformasi, gaya isian, dan <code>imageSmoothingEnabled</code>. Terapkan kembali semuanya setelah perubahan ukuran.</p>
<h2>Referensi Lainnya</h2>
<p><a href="/id/tutorials/canvas-2d-game-loop">Loop game Canvas 2D</a> membahas dasar-dasar loop pembaruan/render.</p>
<p><a href="/id/tutorials/pixel-art-rendering">Rendering seni piksel</a> membahas lebih dalam cara menghasilkan grafis piksel yang tajam.</p>
<p><a href="/id/tutorials/mobile-friendly-web-games">Game web yang ramah perangkat seluler</a> membahas input sentuh dan masalah viewport.</p>
<p><a href="/id/tutorials/webgl-fundamentals-for-games">Dasar-dasar WebGL</a> membahas penyiapan viewport dan canvas untuk konteks WebGL.</p>
<p><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a> membahas pengoptimalan ukuran canvas untuk meningkatkan performa.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Service worker untuk menyimpan aset game dalam cache]]></title>
            <link>https://app.cinevva.com/id/tutorials/service-workers-caching</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/service-workers-caching</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Simpan aset game dalam cache dengan service worker: strategi, pembuatan versi, dan pembaruan. Buat game Anda dimuat seketika pada kunjungan berikutnya.]]></description>
            <content:encoded><![CDATA[<h1>Service worker untuk menyimpan aset game dalam cache</h1>
<p>Service worker memungkinkan Anda mengontrol cara aset disimpan dalam cache dan disajikan. Untuk game, ini berarti pemuatan seketika pada kunjungan berikutnya dan dukungan offline.</p>
<h2>1) Dasar-dasar service worker</h2>
<p>Buat <code>sw.js</code> di direktori root Anda:</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>Daftarkan:</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) Strategi cache-first (terbaik untuk aset game)</h2>
<p>Muat dari cache, dan hanya ambil jika belum disimpan dalam cache:</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) Pra-cache aset penting</h2>
<p>Event install sangat cocok untuk melakukan pra-cache:</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) Network-first untuk konten dinamis</h2>
<p>Gunakan network-first untuk hal-hal yang berubah (papan peringkat, data pemain):</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) Stale-while-revalidate</h2>
<p>Segera kembalikan versi yang tersimpan dalam cache, lalu perbarui di latar belakang:</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) Pembuatan versi dan pembersihan cache</h2>
<p>Bersihkan cache lama saat aktivasi:</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) Pisahkan cache berdasarkan jenis</h2>
<p>Atur cache agar lebih mudah dikelola:</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) Range request untuk audio/video</h2>
<p>Satu hal yang perlu diketahui sejak awal: Cache API tidak akan menyimpan respons parsial. Jika respons 206 Partial Content sampai ke cache.put(), metode tersebut akan memunculkan TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported. Jadi, pola untuk media yang disimpan dalam cache selalu sama: lakukan pra-cache (atau ambil) file lengkap sebagai respons 200 biasa, lalu buat sendiri respons 206 dengan memotong byte yang tersimpan dalam cache setiap kali ada permintaan dengan header Range. Jika tidak ingin membuatnya sendiri, plugin range-requests dari Workbox melakukan pemotongan tersebut untuk Anda ketika dipasangkan dengan strategi CacheFirst.</p>
<p>Tangani range request dengan benar (diperlukan untuk berpindah posisi dalam 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) Notifikasi pembaruan</h2>
<p>Beri tahu pemain saat versi baru siap:</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;Versi baru tersedia!&lt;/span&gt;
    &lt;button onclick=&quot;location.reload()&quot;&gt;Perbarui&lt;/button&gt;
  `
  document.body.appendChild(banner)
}
</code></pre>
<h2>10) Service worker game yang lengkap</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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/pwa-offline-games">PWA untuk game offline</a></li>
<li><a href="/id/tutorials/streaming-asset-loading">Pemuatan aset secara streaming</a></li>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/web-audio-api-games">Web Audio API untuk game</a> — menyimpan aset audio dalam cache untuk permainan offline</li>
<li><a href="/id/tutorials/indexeddb-game-saves">Penyimpanan game dengan IndexedDB</a> — menyimpan status game secara persisten bersama aset yang tersimpan dalam cache</li>
</ul>
<h2>Sumber Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">MDN: Service Worker API</a> — referensi API lengkap</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Cache">MDN: Cache API</a> — referensi antarmuka Cache</li>
<li><a href="https://web.dev/learn/pwa/service-workers/">web.dev: Service worker</a> — strategi dan pola penyimpanan cache</li>
<li><a href="https://developer.chrome.com/docs/workbox/">Workbox</a> — pustaka Google untuk alat bantu service worker</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Rilis game web yang dimuat dengan cepat (daftar periksa praktis)]]></title>
            <link>https://app.cinevva.com/id/tutorials/ship-web-game-fast</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/ship-web-game-fast</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Daftar periksa pragmatis langkah demi langkah untuk mempercepat pemuatan game web: batas ukuran, kompresi, caching, format aset, dan pengukuran performa.]]></description>
            <content:encoded><![CDATA[<h1>Rilis game web yang dimuat dengan cepat (daftar periksa praktis)</h1>
<p>Jika pemain tidak dapat <strong>mulai bermain dalam hitungan detik</strong>, mereka akan pergi. Tutorial ini adalah daftar periksa praktis yang dapat Anda terapkan pada hampir semua stack game web (Canvas/WebGL/WebGPU, Pixi/Three/PlayCanvas, engine khusus, dan sebagainya).</p>
<h2>1) Tetapkan batas terlebih dahulu (atau Anda tidak dapat mengoptimalkan)</h2>
<p>Pilih batas yang sesuai dengan perangkat target Anda. Patokan awal yang baik untuk pengalaman “langsung bermain”:</p>
<ul>
<li><strong>JS+CSS yang dikirimkan</strong>: di bawah ~300–500 KB gzip (lebih kecil lebih baik)</li>
<li><strong>Aset penting sebelum input pertama</strong>: di bawah ~2–5 MB setelah dikompresi</li>
<li><strong>Waktu hingga input pertama</strong>: di bawah ~3–5 detik pada perangkat seluler kelas menengah</li>
</ul>
<p>Catat batas-batas ini dan perlakukan seperti kontrak API.</p>
<h2>2) Ukur hal yang tepat</h2>
<p>Di Chrome DevTools:</p>
<ul>
<li>Gunakan <strong>Performance</strong> untuk merekam sesi dan memeriksa tugas yang berjalan lama.</li>
<li>Gunakan <strong>Network</strong> untuk memeriksa:
<ul>
<li>ukuran transfer dibandingkan ukuran setelah didekode</li>
<li>header caching</li>
<li>apakah aset dialirkan secara progresif atau diterima sebagai satu blob raksasa</li>
</ul>
</li>
</ul>
<p>Dalam konteks game, pantau:</p>
<ul>
<li>Waktu hingga “<strong>Pemain dapat bergerak</strong>”</li>
<li>Waktu hingga “<strong>Frame bermakna pertama</strong>”</li>
<li>“<strong>Latensi input pertama</strong>” (input → frame)</li>
</ul>
<p>Pemahaman Anda tentang “latensi input pertama” kini berkaitan dengan metrik standar: <strong>Interaction to Next Paint (INP)</strong> menggantikan First Input Delay sebagai Core Web Vital pada Maret 2024, dan mengukur keseluruhan waktu dari input hingga paint berikutnya di seluruh interaksi, bukan hanya interaksi pertama. Targetkan INP di bawah 200 md pada persentil ke-75, yang merupakan tolok ukur eksternal berguna untuk angka responsivitas yang sudah Anda pantau.</p>
<h2>3) Kompres semua yang memungkinkan</h2>
<p>Pastikan hosting Anda menyajikan:</p>
<ul>
<li><strong>Brotli</strong> (<code>br</code>) untuk aset teks (JS/CSS/HTML/WASM) jika tersedia</li>
<li><strong>gzip</strong> sebagai cadangan</li>
</ul>
<p>Jika host dan CDN Anda mendukungnya, <strong>Zstandard</strong> (<code>zstd</code>) kini menjadi opsi ketiga untuk <code>Content-Encoding</code>. Dukungan ini tersedia di Chrome dan Edge 123+, Firefox 126+, serta Safari 26.3+ (sekitar 80% pengguna), dan dapat mencapai rasio sekelas Brotli dengan biaya CPU server yang lebih rendah, sehingga membantu untuk payload WASM dan JSON berukuran besar. Browser hanya mengiklankan <code>Accept-Encoding: zstd</code> melalui HTTPS, jadi tetap konfigurasikan Brotli dan gzip sebagai cadangan bagi pengguna lainnya.</p>
<p>Untuk build WebAssembly, kompresi sering kali menjadi pembeda antara “langsung dimuat” dan “tidak pernah selesai dimuat.”</p>
<h2>4) Jangan unduh seluruh dunia sejak awal</h2>
<p>Pisahkan game Anda menjadi:</p>
<ul>
<li><strong>Boot</strong>: loader minimal + input + scene pertama</li>
<li><strong>Core</strong>: sistem gameplay utama</li>
<li><strong>Opsional</strong>: level tambahan, kosmetik, tekstur resolusi tinggi, audio bonus, dan sebagainya</li>
</ul>
<p>Muat bagian “Opsional” hanya setelah:</p>
<ul>
<li>pemain sudah dapat bermain, dan/atau</li>
<li>Anda tahu mereka menginginkan konten tersebut (misalnya, mereka telah mencapai menu/level)</li>
</ul>
<h2>5) Pilih format aset yang tepat</h2>
<p>Untuk sebagian besar game web:</p>
<ul>
<li><strong>Gambar</strong>: utamakan <strong>WebP</strong> (atau <strong>AVIF</strong> jika Anda dapat menerima proses encoding yang lebih lambat) untuk UI/latar belakang.</li>
<li><strong>Audio</strong>: utamakan <strong>Ogg Vorbis</strong> untuk penggunaan umum; uji <strong>AAC</strong> untuk ekosistem Apple.</li>
<li><strong>Video</strong>: utamakan <strong>MP4/H.264</strong> untuk kompatibilitas, dan <strong>WebM</strong> jika didukung serta berukuran lebih kecil.</li>
</ul>
<p>Perhatikan biaya decoding: “unduhan lebih kecil” masih dapat berarti “lebih lambat untuk didekode.”</p>
<h2>6) Buat scene pertama tetap kecil dan deterministik</h2>
<p>Hindari hal-hal berikut dalam scene interaktif pertama Anda:</p>
<ul>
<li>kompilasi shader secara besar-besaran</li>
<li>pengunggahan tekstur berukuran sangat besar</li>
<li>pembuatan prosedural yang memblokir thread utama</li>
<li>parsing JSON sinkron terhadap blob berukuran megabita</li>
</ul>
<p>Khusus untuk tekstur, kirimkan <strong>KTX2 dengan Basis Universal</strong> supercompression, bukan PNG atau JPEG. Format ini tetap terkompresi di GPU setelah diunggah (ditranskode ke BC7 di desktop atau ASTC di perangkat seluler saat pemuatan), sehingga memangkas VRAM 4 hingga 8 kali dan membuat proses pengunggahan jauh lebih ringan dibandingkan mendekode PNG berukuran penuh dan mengirim piksel mentah ke GPU.</p>
<p>Jika Anda harus melakukan pekerjaan berat, lakukan:</p>
<ul>
<li>secara bertahap di beberapa frame, atau</li>
<li>di dalam Worker (jika memungkinkan)</li>
</ul>
<h2>7) Terapkan caching secara agresif (tetapi dengan benar)</h2>
<p>Gunakan caching berjangka panjang untuk aset yang dialamatkan berdasarkan konten:</p>
<ul>
<li><code>Cache-Control: public, max-age=31536000, immutable</code></li>
</ul>
<p>Untuk titik masuk HTML, gunakan durasi caching yang singkat agar Anda dapat menerapkan pembaruan dengan aman.</p>
<h2>8) Buat “kegagalan” tampil cepat dan mudah dipahami</h2>
<p>Jika terjadi masalah, tampilkan:</p>
<ul>
<li>pesan kesalahan yang jelas</li>
<li>tombol coba lagi</li>
<li>tautan untuk melaporkan masalah</li>
</ul>
<p>Kegagalan tanpa pemberitahuan menghancurkan kepercayaan.</p>
<h2>9) Jika menggunakan thread Wasm, pahami COOP/COEP</h2>
<p>Jika build Anda bergantung pada <code>SharedArrayBuffer</code> (umum pada beberapa konfigurasi threading Wasm), kemungkinan besar Anda memerlukan header <strong>isolasi lintas origin</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>Anda memiliki dua pilihan untuk header COEP. <code>require-corp</code> adalah opsi yang ketat, tetapi mengharuskan setiap aset lintas origin (tekstur CDN, skrip pihak ketiga) mengirimkan header <code>Cross-Origin-Resource-Policy</code> atau aset tersebut akan diblokir. <code>Cross-Origin-Embedder-Policy: credentialless</code> juga mengaktifkan <code>SharedArrayBuffer</code> dan isolasi lintas origin, tetapi memuat sumber daya lintas origin tanpa kredensial, alih-alih mewajibkan sumber daya tersebut untuk memberikan izin melalui CORP. Jika Anda mengambil aset dari CDN yang tidak Anda kendalikan, <code>credentialless</code> biasanya merupakan pilihan yang lebih mudah.</p>
<p>Lakukan pengujian sejak awal di host sebenarnya, karena header dan embed pihak ketiga dapat menyebabkan masalah.</p>
<h2>10) Pastikan game dapat dimainkan tanpa SDK</h2>
<p>Jika Anda menargetkan alur kerja kreator Cinevva, usahakan untuk menyediakan:</p>
<ul>
<li>satu URL yang selalu memulai game dengan andal</li>
<li>tanpa login wajib untuk memulai demo</li>
<li>kontrol input yang konsisten</li>
</ul>
<p>Dengan begitu, Anda siap mendistribusikan game tanpa mengintegrasikan SDK tambahan.</p>
<p>Terkait:</p>
<ul>
<li><a href="/id/creators">Untuk kreator game</a></li>
<li><a href="/id/ai-content">Kebijakan konten buatan AI</a></li>
<li><a href="/id/tutorials/streaming-asset-loading">Pemuatan aset secara streaming</a> — pola pemuatan progresif untuk game berukuran besar</li>
<li><a href="/id/tutorials/service-workers-caching">Service worker untuk caching game</a> — simpan aset dalam cache agar kunjungan berikutnya dimuat seketika</li>
<li><a href="/id/guides/web-game-engines-comparison">Perbandingan Engine Game Web</a> — ukuran build dan waktu pemuatan di berbagai engine</li>
<li><a href="/id/guides/itch-io-launch-guide">Cara Merilis Game Anda di itch.io</a> — mengoptimalkan game browser untuk embed <a href="http://itch.io">itch.io</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pemuatan aset streaming untuk game web]]></title>
            <link>https://app.cinevva.com/id/tutorials/streaming-asset-loading</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/streaming-asset-loading</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Muat aset game secara bertahap: prioritasi, streaming, caching, dan menampilkan progres. Pertahankan keterlibatan pemain selama pemuatan.]]></description>
            <content:encoded><![CDATA[<h1>Pemuatan aset streaming untuk game web</h1>
<p>Pemain tidak akan menunggu unduhan sebesar 100 MB. Muat aset secara bertahap agar mereka dapat bermain sementara sisanya dimuat di latar belakang.</p>
<h2>1) Strategi pemuatan</h2>
<p>Bagi aset menjadi beberapa tingkat:</p>
<ol>
<li><strong>Kritis</strong> — Diperlukan untuk menampilkan layar pertama (&lt; 1 MB)</li>
<li><strong>Gameplay</strong> — Diperlukan untuk bermain (&lt; 10 MB)</li>
<li><strong>Penyempurnaan</strong> — Sebaiknya tersedia (muat di latar belakang)</li>
</ol>
<h2>2) Pemuat aset dasar</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) Pemuatan dengan progres</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>Perlu diketahui: ketika server mengompresi respons (gzip atau brotli, seperti yang dilakukan sebagian besar CDN secara default), <code>Content-Length</code> adalah ukuran terkompresi, sedangkan potongan data yang Anda baca sudah didekompresi. Akibatnya, <code>received / total</code> 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 <code>onprogress</code> milik <code>XMLHttpRequest</code>, yang melaporkan progres transfer sebenarnya.</p>
<h2>4) Pemuatan batch dengan progres keseluruhan</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
}

// 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) =&gt; {
  console.log(`Loading: ${Math.round(progress * 100)}% (${name})`)
})
</code></pre>
<h2>5) Pemuat dengan antrean prioritas</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()
  }
}

// Usage
const loader = new PriorityLoader()
loader.add('critical.png', 10)  // Load first
loader.add('optional.png', 1)   // Load later
</code></pre>
<h2>6) Pemuatan malas untuk level</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) {
    // 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
  }
}
</code></pre>
<h2>7) Streaming file besar</h2>
<p>Untuk file besar (model 3D, audio), lakukan streaming dan pemrosesan secara bertahap:</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)
  }
}

// For audio: use Media Source Extensions
// For 3D: process mesh data as it arrives
</code></pre>
<h2>8) Menyimpan aset yang telah dimuat dalam cache</h2>
<p>Gabungkan dengan IndexedDB untuk caching persisten:</p>
<pre><code class="language-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) =&gt; {
      const img = new Image()
      img.onload = () =&gt; {
        URL.revokeObjectURL(img.src)
        resolve(img)
      }
      img.src = URL.createObjectURL(blob)
    })
  }
  
  // IndexedDB methods...
}
</code></pre>
<h2>9) Pola layar pemuatan</h2>
<pre><code class="language-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)
  }
}
</code></pre>
<h2>10) Praktik terbaik</h2>
<ul>
<li><strong>Segera tampilkan sesuatu</strong> — bahkan hanya gambar statis</li>
<li><strong>Muat yang terlihat terlebih dahulu</strong> — tekstur untuk tampilan saat ini</li>
<li><strong>Gunakan placeholder</strong> — gambar beresolusi rendah yang kemudian ditingkatkan</li>
<li><strong>Pramuat level berikutnya</strong> — saat pemain masih bermain</li>
<li><strong>Tangani kegagalan dengan baik</strong> — logika percobaan ulang dan fallback</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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/service-workers-caching">Service worker untuk caching game</a></li>
<li><a href="/id/tutorials/indexeddb-game-saves">Penyimpanan progres game dengan IndexedDB</a></li>
<li><a href="/id/tutorials/web-workers-game-logic">Web Worker untuk logika game</a> — dekode dan proses aset di luar thread utama</li>
<li><a href="/id/guides/game-assets-guide">Tempat menemukan aset game gratis</a> — sumber model 3D, tekstur, dan audio untuk streaming</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">MDN: Fetch API</a> — streaming respons dan ReadableStream</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream">MDN: ReadableStream</a> — memproses potongan data yang di-stream</li>
<li><a href="https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html">Spesifikasi glTF 2.0</a> — format standar untuk streaming aset 3D</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Rendering teks dalam game web]]></title>
            <link>https://app.cinevva.com/id/tutorials/text-rendering-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/text-rendering-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Render teks dalam game: teks Canvas, font bitmap, font SDF, dan performa. Dari label sederhana hingga sistem dialog.]]></description>
            <content:encoded><![CDATA[<h1>Rendering teks dalam game web</h1>
<p>Teks ada di mana-mana dalam game—skor, dialog, menu, angka kerusakan. Tutorial ini membahas berbagai pendekatan dan kapan sebaiknya masing-masing digunakan.</p>
<h2>1) Dasar-dasar teks Canvas</h2>
<p>Pendekatan paling sederhana:</p>
<pre><code class="language-js">ctx.font = '24px Arial'
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('Skor: 1000', canvas.width / 2, 50)
</code></pre>
<p>Jika Anda memerlukan jarak yang lebih rapat atau renggang untuk judul dan label HUD, konteks canvas kini mendukung <code>letterSpacing</code> dan <code>wordSpacing</code> secara langsung. Keduanya menerima string panjang CSS dan mencapai Baseline lintas peramban pada 2025, sehingga Anda tidak perlu lagi menggambar glif satu per satu untuk meniru 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>Atur ulang keduanya ke <code>'0px'</code> setelah selesai karena nilainya tetap tersimpan dalam konteks seperti <code>font</code> dan <code>fillStyle</code>.</p>
<h2>2) Teks dengan garis luar</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) Teks dengan bayangan</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'
  
  // Bayangan
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillText(text, x + shadowOffset, y + shadowOffset)
  
  // Teks utama
  ctx.fillStyle = color
  ctx.fillText(text, x, y)
}
</code></pre>
<h2>4) Rendering font bitmap</h2>
<p>Untuk game seni piksel atau tampilan yang konsisten di berbagai platform:</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
  }
}

// Penggunaan
const font = new BitmapFont(fontImage, 8, 8, 
  'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?-+:; '
)
font.draw(ctx, 'SKOR: 1000', 10, 10, 2)
</code></pre>
<h2>5) Teks yang dirender sebelumnya untuk performa</h2>
<p>Untuk teks statis, render sekali lalu gunakan kembali:</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
}

// Buat sekali
const titleImage = createTextImage('GAME SAYA', '48px Impact', '#fff')

// Gambar berkali-kali (cepat)
ctx.drawImage(titleImage, x, y)
</code></pre>
<h2>6) Efek mesin tik</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) Teks dengan pembungkusan kata</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) Sistem kotak dialog</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) {
    // Latar belakang kotak
    ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'
    ctx.fillRect(this.x, this.y, this.width, this.height)
    
    // Bingkai
    ctx.strokeStyle = '#fff'
    ctx.lineWidth = 2
    ctx.strokeRect(this.x, this.y, this.width, this.height)
    
    // Teks
    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
      )
    }
    
    // Indikator lanjut
    if (this.isComplete()) {
      ctx.fillText('▼', this.x + this.width - 24, this.y + this.height - 24)
    }
  }
}
</code></pre>
<h2>9) Angka kerusakan melayang</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
  }
}

// Pengelola
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) Teks WebGL (font SDF)</h2>
<p>Untuk game WebGL, font Signed Distance Field dapat diskalakan dengan tajam:</p>
<pre><code class="language-js">// Fragmen shader SDF
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);
  }
`

// Pustaka seperti msdf-bmfont-xml dapat menghasilkan atlas font SDF
</code></pre>
<p>Khusus untuk scene three.js, <code>troika-three-text</code> adalah pilihan yang bagus. Alih-alih membuat atlas terlebih dahulu, pustaka ini mengurai file <code>.ttf</code>/<code>.otf</code>/<code>.woff</code> dan menghasilkan glif SDF secara dinamis di web worker. Pustaka ini juga menangani kerning, ligatur, dan aksara kanan-ke-kiri untuk Anda.</p>
<p>Untuk sebagian besar game Canvas 2D, font bitmap atau teks Canvas yang dirender sebelumnya dapat digunakan dengan baik.</p>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/canvas-2d-game-loop">Loop game Canvas 2D</a></li>
<li><a href="/id/tutorials/pixel-art-rendering">Rendering seni piksel</a></li>
<li><a href="/id/tutorials/webgl-fundamentals-for-games">Dasar-dasar WebGL</a></li>
<li><a href="/id/tutorials/responsive-game-canvas">Canvas game responsif</a> — menskalakan teks sesuai resolusi canvas dan DPI</li>
<li><a href="/id/guides/game-assets-guide">Tempat Menemukan Aset Game Gratis</a> — sumber font dan aset UI</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Tone.js untuk audio game]]></title>
            <link>https://app.cinevva.com/id/tutorials/tone-js-game-audio</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/tone-js-game-audio</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Buat suara game yang memuaskan dengan Tone.js: efek suara berbasis synth, musik dinamis, dan audio reaktif yang merespons gameplay.]]></description>
            <content:encoded><![CDATA[<h1>Tone.js untuk Audio Game</h1>
<p>Tone.js berjalan di atas Web Audio API dan membuat audio sintetis lebih praktis. Alih-alih memuat berkas audio, Anda menghasilkan suara secara terprogram. Ini berarti tanpa waktu pemuatan, variasi tak terbatas, dan suara yang dapat bereaksi terhadap gameplay secara real-time.</p>
<p><strong>Cobalah (klik tombol untuk mendengarkan suara game sintetis):</strong></p>
<ToneDemo />
<h2>Mengapa Memilih Tone.js daripada Berkas Audio</h2>
<p>Berkas audio cocok untuk musik dan suara yang kompleks. Namun, untuk efek suara game, sintesis memiliki keunggulan nyata. Anda dapat memvariasikan nada dan tempo setiap kali suara diputar agar tidak terasa repetitif. Anda dapat menghasilkan suara secara langsung berdasarkan kondisi game. Selain itu, Anda sepenuhnya menghindari permintaan jaringan.</p>
<p>Kekurangannya, audio sintetis menggunakan lebih banyak CPU daripada memutar berkas. Untuk game sederhana, hal ini bukan masalah. Untuk game yang kompleks, Anda dapat menggabungkan kedua pendekatan tersebut.</p>
<h2>Penyiapan</h2>
<p>Muat Tone.js dari CDN atau 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>Atau dengan 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>Masalah Autoplay</h2>
<p>Browser memblokir audio hingga pengguna berinteraksi. Tone.js menangani hal ini, tetapi Anda perlu memanggil <code>Tone.start()</code> saat pengguna melakukan gestur:</p>
<pre><code class="language-js">document.addEventListener('click', async () =&gt; {
  await Tone.start()
  console.log('Audio siap')
}, { once: true })
</code></pre>
<p>Tanpa ini, AudioContext akan tetap ditangguhkan dan tidak ada suara yang diputar.</p>
<h2>Synth Pertama Anda</h2>
<p>Synth menghasilkan suara. Yang paling sederhana adalah <code>Tone.Synth</code>:</p>
<pre><code class="language-js">const synth = new Tone.Synth().toDestination()

// Putar sebuah nada
synth.triggerAttackRelease('C4', '8n')
</code></pre>
<p><code>triggerAttackRelease</code> memainkan nada selama durasi tertentu. <code>'C4'</code> adalah nada C tengah. <code>'8n'</code> adalah not seperdelapan pada tempo saat ini.</p>
<p>Synth memiliki osilator (sumber suara) dan envelope (cara suara muncul dan menghilang). Anda dapat menyesuaikan keduanya:</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>Jenis osilator mencakup <code>sine</code> (murni, lembut), <code>triangle</code> (halus), <code>sawtooth</code> (berdengung), dan <code>square</code> (berongga, retro).</p>
<h2>Membuat Sistem Efek Suara</h2>
<p>Untuk game, Anda memerlukan beberapa synth untuk berbagai jenis suara. Berikut pola yang bekerja dengan baik:</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
  
  // Kontrol volume utama
  sfxGain = new Tone.Gain(0.7).toDestination()
  
  // Synth lembut untuk suara melodis
  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)
  
  // Sub-bass untuk benturan yang dalam
  subSynth = new Tone.Synth({
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.3,
      sustain: 0.1,
      release: 0.3,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // Noise untuk tekstur
  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> memungkinkan Anda memainkan beberapa nada sekaligus. <code>Synth</code> biasa bersifat monofonik.</p>
<h2>Contoh Suara Game</h2>
<p>Berikut beberapa suara game umum dan cara membuatnya:</p>
<p><strong>Mengambil koin</strong> - rangkaian nada yang naik terasa memuaskan:</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>Melompat</strong> - sapuan nada naik yang cepat:</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>Terkena serangan/kerusakan</strong> - nada menurun dengan noise:</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>Kematian</strong> - nada-nada sedih yang menurun:</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>Menambahkan Efek</h2>
<p>Efek membuat suara menjadi lebih kaya. Rangkai efek di antara synth dan destination:</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)

// Rangkaian: synth -&gt; filter -&gt; reverb -&gt; gain -&gt; destination
filter.connect(reverb)
reverb.connect(gain)
gain.toDestination()

const synth = new Tone.Synth().connect(filter)
</code></pre>
<p>Filter lowpass memangkas frekuensi tinggi yang tajam. Reverb menambahkan kesan ruang. Gain mengontrol volume.</p>
<h2>Pembatasan Frekuensi Pemutaran</h2>
<p>Jika game Anda memicu banyak suara dengan cepat, suara-suara tersebut dapat tumpang tindih dan menjadi kacau. Tambahkan pembatasan frekuensi pemutaran:</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>Musik Latar Dinamis</h2>
<p>Untuk musik yang bereaksi terhadap gameplay, gunakan sistem berbasis ketukan:</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()
  
  // Bass pada ketukan pertama
  if (beatInMeasure === 0) {
    bassSynth.triggerAttackRelease(chord.root, '4n', now, 0.6)
  }
  
  // Kick drum - hanya saat energi tinggi
  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)
}

// Peristiwa dalam game memengaruhi energi
function onEnemyKill() {
  energy = Math.min(1, energy + 0.1)
}
</code></pre>
<p>Intensitas musik berubah berdasarkan apa yang terjadi di dalam game.</p>
<h2>Drum dengan MembraneSynth dan NoiseSynth</h2>
<p>Untuk perkusi, gunakan synth khusus:</p>
<pre><code class="language-js">// Kick drum
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')

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

// Hi-hat
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>Pembersihan</h2>
<p>Buang synth saat Anda selesai menggunakannya untuk membebaskan sumber daya:</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>Kesalahan Umum</h2>
<p><strong>Tidak menunggu Tone.start()</strong> - Audio tidak akan diputar hingga context dimulai melalui interaksi pengguna.</p>
<p><strong>Membuat synth setiap kali suara diputar</strong> - Buat synth sekali saat inisialisasi, lalu gunakan kembali. Membuat synth baru setiap kali akan menyebabkan kebocoran memori.</p>
<p><strong>Terlalu banyak suara yang tumpang tindih</strong> - Gunakan pembatasan frekuensi pemutaran dan deduplikasi untuk mencegah audio terdengar kacau.</p>
<p><strong>Frekuensi yang tajam</strong> - Gunakan filter lowpass dan jenis osilator yang lebih lembut (sine, triangle) agar suara tidak mengganggu pemain.</p>
<h2>Referensi Lainnya</h2>
<p><a href="/id/tutorials/web-audio-api-games">Web Audio API untuk game</a> membahas API yang mendasarinya serta cara memuat berkas audio.</p>
<p><a href="https://tonejs.github.io/">Dokumentasi Tone.js</a> menyediakan referensi API lengkap.</p>
<p><a href="/id/guides/game-assets-guide">Tempat Menemukan Aset Game Gratis</a> mencakup penyedia audio dan efek suara seperti Freesound.</p>
<p><a href="/id/tutorials/mobile-friendly-web-games">Game web yang ramah perangkat seluler</a> membahas pembatasan audio iOS dan pola sentuh-untuk-membuka-kunci.</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web Audio API untuk game]]></title>
            <link>https://app.cinevva.com/id/tutorials/web-audio-api-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/web-audio-api-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Implementasikan audio game dengan Web Audio API: efek suara, musik, audio spasial, dan mixing. Pola praktis yang terbukti berfungsi.]]></description>
            <content:encoded><![CDATA[<h1>Web Audio API untuk Game</h1>
<p>Audio yang bagus dapat mengubah sebuah game. Game tanpa suara terasa tidak hidup. Web Audio API menyediakan efek suara berlatensi rendah, pemutaran musik, dan bahkan audio spasial 3D, semuanya langsung di browser.</p>
<p><strong>Cobalah (klik tombol untuk mendengar suara yang disintesis):</strong></p>
<AudioDemo />
<h2>Masalah Autoplay</h2>
<p>Sebelum melakukan hal lain, Anda perlu memahami pembatasan autoplay. Browser memblokir audio hingga pengguna berinteraksi dengan halaman Anda. Ini merupakan hal yang baik (tidak ada yang ingin situs web tiba-tiba memutar suara saat dimuat), tetapi artinya Anda harus menginisialisasi audio sebagai respons terhadap klik, ketukan, atau penekanan tombol.</p>
<pre><code class="language-js">let audioCtx = null

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

// Inisialisasi pada interaksi pertama
document.addEventListener('click', () =&gt; initAudio(), { once: true })
document.addEventListener('keydown', () =&gt; initAudio(), { once: true })
</code></pre>
<p>Jika Anda mencoba memutar audio sebelum ini, prosesnya akan gagal tanpa pesan atau AudioContext tetap ditangguhkan. Hal ini sering membuat banyak developer lengah.</p>
<h2>Memuat Suara</h2>
<p>File audio perlu didekode sebelum diputar. Ambil file, dekode, lalu simpan buffer agar dapat digunakan kembali:</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>Proses decoding membutuhkan waktu, jadi muat semua suara Anda selama layar pemuatan, bukan ketika suara tersebut perlu diputar.</p>
<h2>Memutar Suara</h2>
<p>Untuk memutar suara, buat sumber buffer, hubungkan melalui gain node untuk mengontrol volume, lalu mulai pemutarannya:</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>Sumber buffer hanya dapat digunakan sekali. Setelah sumber selesai diputar, Anda tidak dapat memulainya kembali. Buat sumber baru setiap kali Anda memutar suara. Ini mungkin terasa boros, tetapi begitulah cara kerja API ini, dan browser telah mengoptimalkannya.</p>
<h2>Sound Manager</h2>
<p>Untuk game sungguhan, bungkus semuanya dalam sebuah kelas pengelola:</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>Master gain node memungkinkan Anda menerapkan slider volume global. Semua suara dirutekan melaluinya.</p>
<h2>Musik Latar</h2>
<p>Musik memerlukan perulangan serta kemampuan untuk dihentikan atau dipudarkan:</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>Metode <code>linearRampToValueAtTime</code> menghasilkan transisi volume yang mulus alih-alih terputus secara mendadak.</p>
<h2>Audio Spasial</h2>
<p>Untuk game 3D, Anda dapat memosisikan suara di dalam ruang. Suara menjadi lebih pelan saat menjauh dari pendengar:</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>Panggil <code>updateListener</code> pada setiap frame menggunakan posisi kamera atau pemain. Panner secara otomatis menyesuaikan volume dan panning stereo berdasarkan jarak dan arah.</p>
<h2>Suara Tembakan Cepat</h2>
<p>Untuk suara yang diputar berkali-kali per detik (tembakan, langkah kaki), Anda tentu tidak ingin instance yang saling tumpang tindih terus menumpuk. Pola pool sederhana dapat membantu:</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>Pool membatasi jumlah instance dari suara yang sama yang dapat diputar secara bersamaan. Ketika batas tercapai, instance tertua akan digantikan.</p>
<h2>Format Audio</h2>
<p>Format yang berbeda lebih cocok untuk situasi yang berbeda:</p>
<p>MP3 dapat digunakan di mana saja dan cocok untuk musik. OGG Vorbis menawarkan kualitas lebih baik dengan ukuran file yang sama. Safari sebelumnya menjadi browser utama terakhir yang belum mendukungnya, tetapi dukungan pemutaran native Ogg Vorbis telah ditambahkan di Safari 18.4, sehingga kini semua browser utama versi terbaru mendukungnya. Anda tetap sebaiknya menyediakan MP3 sebagai fallback jika ingin mendukung Safari versi lama. AAC/M4A berfungsi dengan baik di perangkat Apple. WAV tidak terkompresi dan berukuran besar, tetapi dapat didekode seketika, sehingga cocok untuk efek suara pendek jika ukuran file bukan masalah. WebM/Opus menawarkan rasio kualitas terhadap ukuran terbaik, tetapi hanya berfungsi di browser modern.</p>
<p>Untuk kompatibilitas maksimal, sediakan fallback:</p>
<pre><code class="language-js">const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'
</code></pre>
<h2>Trik Berguna</h2>
<p><strong>Variasi pitch</strong> membuat suara yang berulang terasa tidak terlalu robotis:</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>Setiap suara tembakan atau langkah kaki akan terdengar sedikit berbeda.</p>
<p><strong>Ducking</strong> menurunkan volume musik selama dialog atau saat suara penting diputar:</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>Hal yang Perlu Diwaspadai di iOS</h2>
<p>iOS memiliki pembatasan audio yang lebih ketat dibandingkan platform lain. AudioContext harus dibuat dan dilanjutkan selama gestur pengguna. Beberapa versi iOS juga mengharuskan Anda memutar suara (bahkan suara hening) selama gestur tersebut, bukan sekadar membuat context. Jika audio berfungsi di desktop tetapi tidak di iOS, kemungkinan besar inilah penyebabnya.</p>
<h2>Referensi Lainnya</h2>
<p><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a> membahas kompresi aset, termasuk audio.</p>
<p><a href="/id/tutorials/mobile-friendly-web-games">Game web yang ramah perangkat seluler</a> membahas lebih lanjut masalah khusus iOS.</p>
<p><a href="/id/tutorials/pwa-offline-games">PWA untuk game offline</a> menunjukkan cara menyimpan file audio dalam cache agar dapat dimainkan secara offline.</p>
<p><a href="/id/tutorials/tone-js-game-audio">Tone.js untuk audio game</a> membahas pembuatan suara prosedural dan sistem musik menggunakan library tingkat tinggi.</p>
<p><a href="/id/guides/game-assets-guide">Tempat Menemukan Aset Game Gratis</a> mencantumkan sumber efek suara dan musik gratis seperti Freesound dan Poly Haven.</p>
<h2>Referensi Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API">MDN: Web Audio API</a> — referensi API lengkap</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioContext">MDN: AudioContext</a> — membuat dan mengelola audio context</li>
<li><a href="https://www.w3.org/TR/webaudio/">Spesifikasi Web Audio API</a> — spesifikasi W3C</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Web Workers untuk logika game]]></title>
            <link>https://app.cinevva.com/id/tutorials/web-workers-game-logic</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/web-workers-game-logic</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Alihkan komputasi berat ke Web Workers: fisika, pencarian jalur, AI, dan generasi prosedural tanpa memblokir thread utama.]]></description>
            <content:encoded><![CDATA[<h1>Web Workers untuk logika game</h1>
<p>Web Workers memungkinkan Anda menjalankan JavaScript di thread latar belakang. Untuk game, ini berarti fisika, AI, dan generasi prosedural dapat berjalan tanpa menyebabkan penurunan frame rate.</p>
<h2>1) Kapan harus menggunakan Workers</h2>
<p>Cocok digunakan untuk:</p>
<ul>
<li>Simulasi fisika</li>
<li>Pencarian jalur (A*, navmesh)</li>
<li>Pengambilan keputusan AI</li>
<li>Generasi prosedural</li>
<li>Pemrosesan aset (manipulasi gambar, kompresi)</li>
<li>Matematika kompleks (FFT, deteksi tabrakan)</li>
</ul>
<p>Kurang ideal untuk:</p>
<ul>
<li>Rendering (Workers tidak dapat mengakses DOM/Canvas secara langsung)</li>
<li>Tugas yang sangat kecil (overhead thread tidak sepadan)</li>
</ul>
<h2>2) Membuat Worker dasar</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) {
  // Expensive work here
  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('Got result:', data)
  }
}

worker.postMessage({ type: 'calculate', data: 42 })
</code></pre>
<h2>3) Worker inline (tanpa berkas terpisah)</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) Worker modul ES</h2>
<p>Anda dapat menulis kode worker sebagai modul ES dan menggunakan <code>import</code> di dalamnya sebagai pengganti <code>importScripts</code>. Teruskan <code>{ type: 'module' }</code> ke konstruktor:</p>
<pre><code class="language-js">const worker = new Worker('physics-worker.js', { type: 'module' })
</code></pre>
<p>Di dalam worker, Anda kemudian dapat menggunakan <code>import</code> untuk helper matematika, tabrakan, atau AI bersama dengan cara yang sama seperti di thread utama, sehingga logika game tidak perlu diduplikasi. Worker modul didukung di Chrome dan Edge 80+, Safari 15+, serta Firefox 114+.</p>
<h2>5) Contoh Worker fisika</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) {
  // Initialize physics world
}

function step() {
  // Update physics
  for (const body of bodies) {
    body.vy += 9.8 * FIXED_DT // Gravity
    body.x += body.vx * FIXED_DT
    body.y += body.vy * FIXED_DT
  }
  
  // Send positions back
  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) Objek transferable untuk performa</h2>
<p>Data berukuran besar dapat ditransfer tanpa disalin:</p>
<pre><code class="language-js">// Main thread
const positions = new Float32Array(1000)
worker.postMessage(positions, [positions.buffer])
// positions is now unusable here (transferred)

// Worker
self.onmessage = (e) =&gt; {
  const positions = e.data
  // Work with positions
  self.postMessage(positions, [positions.buffer])
}
</code></pre>
<h2>7) Worker pencarian jalur</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) {
  // A* implementation
  const openSet = [start]
  const cameFrom = new Map()
  const gScore = new Map()
  gScore.set(key(start), 0)
  
  while (openSet.length &gt; 0) {
    // ... A* logic
  }
  
  return reconstructPath(cameFrom, end)
}

function key(pos) {
  return `${pos.x},${pos.y}`
}
</code></pre>
<h2>8) Pool Worker untuk tugas paralel</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 untuk sinkronisasi real-time</h2>
<p>Dengan isolasi lintas origin yang diaktifkan, Anda dapat berbagi memori:</p>
<pre><code class="language-js">// Main thread
const shared = new SharedArrayBuffer(1024)
const positions = new Float32Array(shared)

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

// Worker reads/writes directly to shared memory
// No postMessage overhead for position updates
</code></pre>
<h2>10) OffscreenCanvas untuk Workers</h2>
<p>Lakukan rendering di dalam Worker. OffscreenCanvas kini tersedia secara luas sebagai Baseline di Chrome, Edge, Firefox, dan Safari (Safari menambahkan dukungan pada versi 17.0 di macOS dan iOS):</p>
<pre><code class="language-js">// Main thread
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)
    // Draw...
    requestAnimationFrame(render)
  }
  render()
}
</code></pre>
<h2>11) Penanganan error</h2>
<pre><code class="language-js">worker.onerror = (e) =&gt; {
  console.error('Worker error:', e.message, e.filename, e.lineno)
}

// In worker
self.onerror = (e) =&gt; {
  self.postMessage({ type: 'error', message: e.message })
}
</code></pre>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/coop-coep-sharedarraybuffer">Aktifkan thread Wasm (SharedArrayBuffer)</a></li>
<li><a href="/id/tutorials/canvas-2d-game-loop">Game loop Canvas 2D</a></li>
<li><a href="/id/tutorials/game-physics-libraries">Pustaka fisika game</a> — menjalankan Rapier atau Cannon-es di dalam Worker</li>
<li><a href="/id/tutorials/streaming-asset-loading">Pemuatan aset secara streaming</a> — mendekode aset di luar thread utama</li>
<li><a href="/id/guides/web-games-stack-2026">Stack teknologi game web pada 2026</a> — cara Workers terintegrasi ke dalam stack WebGL/WebGPU/Wasm</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API">MDN: API Web Workers</a> — referensi API lengkap</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">MDN: Menggunakan Web Workers</a> — panduan langkah demi langkah</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects">MDN: Objek transferable</a> — transfer data tanpa penyalinan</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">MDN: SharedArrayBuffer</a> — memori bersama antar-thread</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Dasar-dasar WebGL untuk pengembang game]]></title>
            <link>https://app.cinevva.com/id/tutorials/webgl-fundamentals-for-games</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/webgl-fundamentals-for-games</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Pelajari dasar-dasar WebGL untuk game: shader, buffer, tekstur, dan loop render. Kode praktis yang dapat Anda salin dan kembangkan.]]></description>
            <content:encoded><![CDATA[<h1>Dasar-dasar WebGL untuk pengembang game</h1>
<p>WebGL memberi Anda akses langsung ke GPU di browser. Teknologi ini menjadi fondasi untuk game web 3D (dan 2D berperforma tinggi). Tutorial ini membahas hal-hal pentingnya.</p>
<h2>1) Mendapatkan konteks 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>Utamakan <code>webgl2</code> jika tersedia—dukungannya luas dan menawarkan fitur yang lebih baik.</p>
<h2>2) Pipeline render (disederhanakan)</h2>
<ol>
<li><strong>Shader vertex</strong> dijalankan sekali untuk setiap vertex. Shader ini menentukan posisi geometri Anda.</li>
<li><strong>Shader fragmen</strong> dijalankan sekali untuk setiap piksel. Shader ini memberi warna pada hasil render.</li>
<li>Buffer menyimpan data (posisi, UV, warna).</li>
<li>Tekstur adalah gambar yang diambil sampelnya oleh shader.</li>
</ol>
<h2>3) Pasangan shader minimal</h2>
<p><strong>Shader vertex:</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 fragmen:</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) Mengompilasi dan menautkan shader</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) Membuat buffer</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) Menghubungkan buffer ke shader</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) Loop game</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) Memuat tekstur</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, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
    gl.generateMipmap(gl.TEXTURE_2D)
  }
  img.src = url
  return texture
}
</code></pre>
<h2>9) Kendala umum</h2>
<ul>
<li><strong>Tekstur berpangkat dua</strong> tidak mutlak diperlukan di WebGL 2, tetapi mipmap bekerja lebih baik dengannya.</li>
<li><strong>Konteks hilang</strong>: tangani peristiwa <code>webglcontextlost</code>—browser seluler akan mengeluarkan konteks saat memori terbatas.</li>
<li><strong>Tekstur float</strong> memerlukan pemeriksaan ekstensi (<code>EXT_color_buffer_float</code>).</li>
</ul>
<h2>10) Kapan harus menggunakan pustaka</h2>
<p>WebGL mentah memerlukan banyak kode. Untuk sebagian besar game, pertimbangkan:</p>
<ul>
<li><strong>Three.js</strong> — 3D dengan fitur lengkap</li>
<li><strong>PixiJS</strong> — rendering 2D cepat</li>
<li><strong>PlayCanvas</strong> — mesin game dengan editor</li>
</ul>
<p>Gunakan WebGL mentah saat Anda membutuhkan:</p>
<ul>
<li>kendali maksimum</li>
<li>renderer khusus</li>
<li>pemahaman tentang cara kerja sebenarnya</li>
</ul>
<p>Satu catatan untuk 2026: WebGPU kini tersedia secara default di semua browser utama (Chrome dan Edge sejak versi 113, Firefox 141 di Windows dan 145 di macOS, serta Safari di iOS 26, iPadOS 26, dan macOS Tahoe 26). Dasar-dasar di sini tetap berlaku karena pustaka seperti Three.js menjalankan renderer WebGPU dengan fallback otomatis ke WebGL 2, sehingga WebGL 2 tetap menjadi standar dasar yang dapat diandalkan. Jika Anda membuat renderer baru dari nol saat ini, baca tutorial WebGPU bersama tutorial ini sebelum menetapkan pilihan API.</p>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/webgpu-getting-started">WebGPU untuk game</a></li>
<li><a href="/id/tutorials/canvas-2d-game-loop">Loop game Canvas 2D</a></li>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/guides/web-games-stack-2026">Stack Teknologi Game Web pada 2026</a> — kapan memilih WebGL, WebGPU, atau Wasm</li>
<li><a href="/id/tutorials/game-physics-libraries">Pustaka fisika game</a> — mesin fisika yang melakukan rendering melalui WebGL</li>
<li><a href="/id/guides/web-game-engines-comparison">Perbandingan Mesin Game Web</a> — Three.js, PixiJS, PlayCanvas, dan lainnya</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API">MDN: API WebGL</a> — referensi API lengkap</li>
<li><a href="https://webgl2fundamentals.org/">Dasar-Dasar WebGL2</a> — seri tutorial WebGL2 mendalam</li>
<li><a href="https://thebookofshaders.com/">The Book of Shaders</a> — panduan interaktif untuk shader fragmen GLSL</li>
<li><a href="https://threejs.org/docs/">Dokumentasi Three.js</a> — framework WebGL paling populer</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Memulai WebGPU untuk pengembang game]]></title>
            <link>https://app.cinevva.com/id/tutorials/webgpu-getting-started</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/webgpu-getting-started</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Mulai menggunakan WebGPU: penyiapan perangkat, pipeline, shader, dan rendering. API GPU modern untuk generasi baru game web.]]></description>
            <content:encoded><![CDATA[<h1>Memulai WebGPU untuk pengembang game</h1>
<p>WebGPU adalah penerus WebGL. API ini lebih rendah tingkatnya, lebih eksplisit, dan dirancang untuk GPU modern. Jika Anda memulai proyek 3D baru pada tahun 2026, WebGPU layak dipelajari.</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 WebGPU: 10 proyek yang mencakup penyiapan hingga rendering 3D dengan pencahayaan dan animasi</p>
<h2>1) Periksa dukungan browser</h2>
<pre><code class="language-js">if (!navigator.gpu) {
  console.error('WebGPU not supported')
}
</code></pre>
<p>Pada tahun 2026, WebGPU tersedia di Chrome, Edge, Firefox, dan Safari.</p>
<h2>2) Dapatkan perangkat</h2>
<pre><code class="language-js">const adapter = await navigator.gpu.requestAdapter()
const device = await adapter.requestDevice()
</code></pre>
<p>Adapter merepresentasikan GPU fisik. Perangkat adalah antarmuka Anda untuk berinteraksi dengannya.</p>
<p>Kedua pemanggilan dapat gagal. <code>requestAdapter()</code> mengembalikan <code>null</code> ketika sistem tidak memiliki GPU yang dapat digunakan, sedangkan <code>requestDevice()</code> dapat ditolak, jadi tangani kemungkinan tersebut sebelum melanjutkan:</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>Adapter juga dianggap telah digunakan setelah Anda membuat perangkat darinya, jadi minta adapter baru jika Anda memerlukan perangkat lain.</p>
<h2>3) Konfigurasikan kanvas</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) Tulis shader dalam WGSL</h2>
<p>WebGPU menggunakan WGSL (WebGPU Shading Language), bukan 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) Buat modul shader</h2>
<pre><code class="language-js">const shaderModule = device.createShaderModule({
  code: wgslSource,
})
</code></pre>
<h2>6) Buat pipeline rendering</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) Render sebuah frame</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) Buffer di 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) Bind group untuk uniform</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 vs WebGL</h2>
<table>
<thead>
<tr>
<th>Aspek</th>
<th>WebGL</th>
<th>WebGPU</th>
</tr>
</thead>
<tbody>
<tr>
<td>Gaya API</td>
<td>Seperti OpenGL, berbasis status</td>
<td>Modern, eksplisit</td>
</tr>
<tr>
<td>Shader</td>
<td>GLSL</td>
<td>WGSL</td>
</tr>
<tr>
<td>Komputasi</td>
<td>Terbatas (ekstensi)</td>
<td>Dukungan kelas utama</td>
</tr>
<tr>
<td>Multi-threading</td>
<td>Sulit</td>
<td>Dirancang untuk itu</td>
</tr>
<tr>
<td>Dukungan browser</td>
<td>Universal</td>
<td>Semua browser utama (Chrome, Edge, Safari 26, Firefox)</td>
</tr>
</tbody>
</table>
<h2>Kapan sebaiknya menggunakan WebGPU</h2>
<ul>
<li>Proyek baru yang menargetkan browser modern</li>
<li>Beban kerja komputasi berat (fisika, AI, generasi prosedural)</li>
<li>Saat Anda memerlukan kontrol mendetail atas sumber daya GPU</li>
</ul>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/webgl-fundamentals-for-games">Dasar-dasar WebGL</a></li>
<li><a href="/id/tutorials/web-workers-game-logic">Web Worker untuk logika game</a></li>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/guides/web-games-stack-2026">Stack Teknologi Game Web pada 2026</a> — kerangka pengambilan keputusan untuk WebGL vs WebGPU vs Wasm</li>
<li><a href="/id/tutorials/coop-coep-sharedarraybuffer">COOP/COEP dan SharedArrayBuffer</a> — header yang diperlukan untuk threading Wasm dengan komputasi WebGPU</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://www.w3.org/TR/webgpu/">Spesifikasi WebGPU</a> — spesifikasi W3C</li>
<li><a href="https://www.w3.org/TR/WGSL/">Spesifikasi WGSL</a> — spesifikasi WebGPU Shading Language</li>
<li><a href="https://webgpufundamentals.org/">Dasar-Dasar WebGPU</a> — seri tutorial mendalam</li>
<li><a href="https://codelabs.developers.google.com/your-first-webgpu-app">Aplikasi WebGPU pertama Anda</a> — panduan langkah demi langkah Google Codelab</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Dasar-dasar multipemain WebSocket]]></title>
            <link>https://app.cinevva.com/id/tutorials/websocket-multiplayer-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/websocket-multiplayer-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Bangun multipemain real-time dengan WebSocket: penanganan koneksi, protokol pesan, dan pola sinkronisasi untuk game web.]]></description>
            <content:encoded><![CDATA[<h1>Dasar-dasar multipemain 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">Membangun game multipemain dengan WebSocket: arsitektur server, ruang permainan, dan sinkronisasi state</p>
<p>WebSocket memungkinkan komunikasi real-time untuk game multipemain. Tutorial ini membahas dasar-dasar di sisi klien.</p>
<h2>1) Menghubungkan ke server</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) Desain protokol pesan</h2>
<p>Tentukan protokol sederhana:</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) Class pengelola koneksi</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) Mengirim input pemain</h2>
<p>Kirim input, bukan posisi. Biarkan server menjadi sumber kebenaran:</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) Menerima state permainan</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) Interpolasi untuk gerakan yang mulus</h2>
<p>Server mengirim snapshot. Lakukan interpolasi di antaranya:</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) Prediksi sisi klien</h2>
<p>Agar kontrol responsif, lakukan prediksi secara lokal dan rekonsiliasi dengan server:</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) Menangani tampilan latensi</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) Pesan biner untuk efisiensi</h2>
<p>Untuk pembaruan berfrekuensi tinggi, gunakan format biner:</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) Contoh server dasar (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>Terkait</h2>
<ul>
<li><a href="/id/tutorials/ship-web-game-fast">Rilis game web yang dimuat dengan cepat</a></li>
<li><a href="/id/tutorials/game-input-handling">Penanganan input game</a></li>
<li><a href="/id/tutorials/web-workers-game-logic">Web Worker untuk logika game</a></li>
<li><a href="/id/tutorials/coop-coep-sharedarraybuffer">COOP/COEP dan SharedArrayBuffer</a> — header yang diperlukan untuk SharedArrayBuffer dalam konteks multipemain</li>
<li><a href="/id/guides/co-op-game-design">Desain Game Kooperatif pada 2026</a> — alasan game kooperatif &quot;friend slop&quot; sedang meledak dan bagaimana multipemain browser berperan di dalamnya</li>
<li><a href="/id/guides/game-jams-hackathons">Game Jam &amp; Hackathon</a> — game multipemain untuk jam lebih menarik perhatian</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API">MDN: API WebSocket</a> — referensi WebSocket untuk browser</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API">MDN: API WebRTC</a> — koneksi peer-to-peer untuk latensi yang lebih rendah</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API">MDN: API WebTransport</a> — transportasi HTTP/3 yang lebih baru dan mencapai Baseline lintas browser pada Maret 2026 setelah Safari 26.4 menyediakan dukungan (sebelumnya sudah tersedia di Chrome 97+, Edge, dan Firefox 114+). Tidak seperti satu WebSocket berbasis TCP, WebTransport berjalan di atas QUIC serta menawarkan datagram yang tidak andal dan beberapa stream independen, sehingga satu paket yang hilang tidak menyebabkan head-of-line blocking pada semua hal lainnya. Pola yang umum adalah mengirim pembaruan posisi berfrekuensi tinggi melalui datagram, sementara chat atau papan skor tetap menggunakan stream yang andal. WebSocket masih menjadi pilihan yang paling kompatibel dan semua pembahasan di atas tetap berlaku, tetapi WebTransport kini layak dipertimbangkan untuk game yang sensitif terhadap latensi.</li>
<li><a href="https://socket.io/docs/v4/">Dokumentasi Socket.IO</a> — pustaka wrapper WebSocket populer</li>
<li><a href="https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking">Valve Source Multiplayer Networking</a> — pola jaringan otoritatif yang digunakan dalam game produksi</li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Dasar-dasar WebXR untuk game VR]]></title>
            <link>https://app.cinevva.com/id/tutorials/webxr-basics</link>
            <guid isPermaLink="true">https://app.cinevva.com/id/tutorials/webxr-basics</guid>
            <pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Mulai menggunakan WebXR: penyiapan sesi, rendering, pengontrol, dan interaksi. Buat pengalaman VR yang berjalan di browser.]]></description>
            <content:encoded><![CDATA[<h1>Dasar-dasar WebXR untuk game VR</h1>
<p>WebXR menghadirkan VR dan AR ke browser. Pemain dapat menikmati game Anda melalui headset tanpa perlu mengunduh apa pun.</p>
<h2>1) Periksa dukungan 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 }
}

// Usage
const support = await checkXRSupport()
if (support.vr) {
  showVRButton()
}
</code></pre>
<h2>2) Memulai sesi VR</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)
    
    // Set up rendering
    const gl = canvas.getContext('webgl2', { xrCompatible: true })
    await xrSession.updateRenderState({
      baseLayer: new XRWebGLLayer(xrSession, gl),
    })
    
    // Get reference space
    xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
    
    // Start render loop
    xrSession.requestAnimationFrame(onXRFrame)
  } catch (err) {
    console.error('Failed to start VR:', err)
  }
}

function onSessionEnd() {
  xrSession = null
  xrRefSpace = null
}
</code></pre>
<h2>3) Loop rendering 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)
  
  // Render for each eye
  for (const view of pose.views) {
    const viewport = glLayer.getViewport(view)
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
    
    // Get camera matrices
    const viewMatrix = view.transform.inverse.matrix
    const projectionMatrix = view.projectionMatrix
    
    // Render scene with these matrices
    renderScene(viewMatrix, projectionMatrix)
  }
}
</code></pre>
<h2>4) Sumber input (pengontrol)</h2>
<pre><code class="language-js">xrSession.addEventListener('inputsourceschange', (e) =&gt; {
  for (const source of e.added) {
    console.log('Controller added:', source.handedness) // 'left' or '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
    
    // Trigger (usually index 0)
    const triggerPressed = gamepad.buttons[0]?.pressed
    const triggerValue = gamepad.buttons[0]?.value || 0
    
    // Grip (usually index 1)
    const gripPressed = gamepad.buttons[1]?.pressed
    
    // Thumbstick
    const thumbstickX = gamepad.axes[2] || 0
    const thumbstickY = gamepad.axes[3] || 0
    
    // A/B buttons (index 4, 5)
    const buttonA = gamepad.buttons[4]?.pressed
    const buttonB = gamepad.buttons[5]?.pressed
  }
}
</code></pre>
<h3>Jangan berasumsi setiap perangkat memiliki gamepad</h3>
<p>Kode di atas membaca <code>source.gamepad</code> untuk trigger dan thumbstick. Cara ini berfungsi pada headset dengan pengontrol seperti Meta Quest, tetapi tidak melakukan apa pun di Apple Vision Pro (WebXR diaktifkan secara default di Safari pada visionOS 2), Samsung Galaxy XR, dan perangkat Android XR lainnya yang menggunakan tatapan-dan-cubitan atau pelacakan tangan sebagai input utama. Pada headset tersebut, <code>source.gamepad</code> bernilai null dan <code>session.inputSources</code> tetap kosong sampai pengguna benar-benar melakukan gerakan mencubit.</p>
<p>Untuk satu interaksi yang dijamin tersedia di setiap perangkat, yaitu &quot;tindakan utama&quot;, dengarkan event <code>select</code> alih-alih memeriksa tombol secara terus-menerus. WebXR mengirimkan <code>selectstart</code>, <code>select</code>, dan <code>selectend</code> baik untuk trigger pengontrol, cubitan tangan, maupun tatapan-dan-cubitan Vision Pro, sehingga satu handler dapat menangani semuanya:</p>
<pre><code class="language-js">xrSession.addEventListener('selectstart', (e) =&gt; {
  // e.inputSource is the controller, hand, or transient pinch
  // e.frame lets you read its targetRaySpace pose
})
xrSession.addEventListener('select', (e) =&gt; {
  // primary action completed (trigger pulled, pinch released on target)
})
xrSession.addEventListener('selectend', (e) =&gt; {})
</code></pre>
<p>Pertahankan kode gamepad untuk pergerakan dengan thumbstick dan tombol tambahan pada headset dengan pengontrol, tetapi lindungi kode tersebut dengan <code>if (source.gamepad)</code> dan arahkan interaksi inti &quot;pilih/pegang/aktifkan&quot; melalui event ini agar game tetap berfungsi pada perangkat yang dikendalikan dengan tangan dan tatapan.</p>
<h2>5) Posisi pengontrol</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) Ray casting untuk menunjuk</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) Pergerakan teleportasi</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) {
        // Aiming
        this.isAiming = true
        const ray = getControllerRay(frame, source)
        this.targetPosition = this.findTeleportTarget(ray)
      } else if (this.isAiming) {
        // Release - teleport
        if (this.targetPosition) {
          player.position.x = this.targetPosition.x
          player.position.z = this.targetPosition.z
        }
        this.isAiming = false
        this.targetPosition = null
      }
    }
  }
  
  findTeleportTarget(ray) {
    // Intersect with ground plane
    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) Pergerakan mulus</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
    
    // Apply deadzone
    if (Math.abs(moveX) &lt; 0.1) moveX = 0
    if (Math.abs(moveY) &lt; 0.1) moveY = 0
    
    // Get head direction for movement
    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] }
    
    // Normalize to XZ plane
    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) Panduan kenyamanan VR</h2>
<pre><code class="language-js">// Vignette during movement to reduce motion sickness
function renderComfortVignette(movementSpeed) {
  const intensity = Math.min(movementSpeed / 5, 0.5)
  if (intensity &lt; 0.1) return
  
  // Render dark edges that increase with speed
  renderVignette(intensity)
}

// Snap turning
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 degrees
    player.rotation += angle
    snapTurnCooldown = 0.3 // Prevent rapid turns
  }
}
</code></pre>
<h2>10) Penyiapan WebXR lengkap</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
    
    // Update game logic
    this.teleport.update(frame, this.session.inputSources)
    
    // Render
    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) {
    // Your rendering code here
  }
  
  onEnd() {
    this.session = null
  }
}
</code></pre>
<h2>Terkait</h2>
<ul>
<li><a href="/id/tutorials/webgl-fundamentals-for-games">Dasar-dasar WebGL</a></li>
<li><a href="/id/tutorials/webgpu-getting-started">Memulai dengan WebGPU</a></li>
<li><a href="/id/tutorials/game-input-handling">Penanganan input game</a></li>
<li><a href="/id/guides/web-games-stack-2026">Tumpukan teknologi game web pada 2026</a> — posisi WebXR dalam lanskap teknologi yang lebih luas</li>
<li><a href="/id/guides/threejs-usdc-tech-report">Three.js + USDC di browser</a> — memuat aset 3D untuk adegan XR</li>
</ul>
<h2>Sumber Daya Eksternal</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API">MDN: WebXR Device API</a> — referensi API lengkap</li>
<li><a href="https://immersiveweb.dev/">Immersive Web</a> — contoh WebXR, alat, dan tabel dukungan browser</li>
<li><a href="https://threejs.org/docs/#manual/en/introduction/How-to-create-VR-content">Panduan WebXR Three.js</a> — konten VR dengan Three.js</li>
</ul>
]]></content:encoded>
        </item>
    </channel>
</rss>
