diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md
index 733d53c..c4c956b 100644
--- a/docs/ENGINE_NOTES.md
+++ b/docs/ENGINE_NOTES.md
@@ -23,6 +23,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- Tata letak sentuh yang bisa diatur (RENCANA 4.6): `touchlayout.js` + `touchlayout.css`, dimuat `defer` di `
` index.html (satu baris tag baru bersama manifest dan `pwa.js`); hanya aktif di `html.touch`, `touch.js` tidak diubah. Pengaturan mendapat baris `#tl-row` (disisipkan lewat JS sebelum `.dialog-buttons`) yang membuka editor `#tl-editor`: `` modal layar penuh yang menumpuk di atas Pengaturan (Pengaturan disembunyikan lewat `html.tl-editing`, tetap terbuka sehingga `settingsOpen` menahan game; Esc ditangkap fase capture agar game tidak menutup Pengaturan). Di dalamnya ada salinan (`cloneNode`, tanpa pendengar dan `inert`) `.dpad`, `.action-pad`, `.extra-pad` plus kotak seret `.tl-box` dengan pegangan sudut; panah/+/- juga bekerja di papan ketik. Hasil ke kontrol asli hanya kelas `.tl-xf` + variabel `--tl-x/--tl-y/--tl-s` (`transform: translate() scale()`, titik tumpu sudut dekat tepi); elemen bawaan tidak disentuh sama sekali. Simpanan `localStorage['konoha.touchlayout.v1']` = `{v:1, g:{dpad|action|extra:{x,y,s}}}` (x,y = pecahan lebar/tinggi layar, s = skala 0,6-1,8). Penjepitan ke layar memakai `getBoundingClientRect` nyata (area = layar - tepi aman - 4 px - bilah editor 46 px di atas), dijalankan `ResizeObserver` pada `#touch-controls` saat kontrol tampil/ukuran berubah; nilai simpanan tidak ditimpa penjepitan. `window.TouchLayout` = `{open(), editing, layout}` (konsol: `TouchLayout.open()`). Belum ada skenario otomatis dan belum pernah dijalankan di peramban oleh penulisnya (hanya `node --check` dan smoke desktop): uji manual di 844x390 dengan `?touch=1`.
- PWA (RENCANA 4.7): `manifest.webmanifest` (id `konoha-fighting`, `display: fullscreen` + `display_override [fullscreen, standalone]`, landscape, warna `#101923`, ikon PNG any dan maskable 192/512) ditautkan di index.html dan mobile.html bersama `rel=icon` dan `apple-touch-icon`. Ikon di `assets/pwa/` dibuat `tools/make_pwa_icons.py` (hanya Pillow) dari `assets/menu/browo-select.webp`; jalankan ulang bila ingin mengganti gambar. `pwa.js` (`window.KonohaPWA = {available(), install(), onChange(fn)}`) dimuat sinkron di `` kedua halaman karena `beforeinstallprompt` datang ke dokumen paling atas: di HP itu `mobile.html`, dan iframe game memakai `window.parent.KonohaPWA` (panggilan langsung supaya gestur ketuk tetap berlaku untuk `prompt()`). Baris `#pwa-row` ("Pasang aplikasi") di Pengaturan hanya tampil bila event tersedia dan belum terpasang; iOS tidak punya event itu, jadi di sana tombol tidak tampil. `sw.js`: perilaku lama dipertahankan (gambar/font cache dulu, HTML/CSS/JS jaringan dulu); navigasi yang gagal dan tidak ada di cache mendapat `offline.html` dari cache kecil `konoha-offline-v1` (terpisah dari `aether-assets` karena `preload.js` menghapus entri yang tidak ada di `precache.js`). `tools/build_precache.py` kini memuat `manifest.webmanifest` dan `assets/pwa/*` (`offline.html`, `pwa.js`, `touchlayout.*` ikut otomatis); `precache.js`/`files.json` perlu dibangun ulang oleh integrator. mp3/mp4 tetap dilayani jaringan, jadi tanpa internet game jalan tanpa musik. Server perlu MIME `application/manifest+json` untuk `.webmanifest` (sudah di `.htaccess`).
- Bantuan dan aksesibilitas (Tahap 4.2 dan 4.3): `access.js` (`window.Access`) + `access.css`, dimuat tepat sesudah `feel.js`. Menyisipkan kotak lipat "Bantuan & aksesibilitas" ke `#settings-panel` (tombol biasa, bukan ``, karena penangan tombol game mencegat Spasi pada ``), setiap pilihan punya tombol Atur ulang (muncul bila nilainya bukan bawaan) plus "Atur ulang semua". Simpanan `localStorage['konoha.access.v1']` hanya berisi nilai non-bawaan; `konoha.access.open` mengingat kotak terbuka. Id pilihan: `combo`, `block`, `nocd`, `regen` (true/false), `text` (0 Normal, 1 Besar, 2 Sangat besar), `cb` (`off`|`pro`|`deu`|`tri`), `calm` (`auto` mengikuti prefers-reduced-motion | `on`), `hc` (true/false). Penerapan lewat kelas pada ``: `acc-t` + `acc-text-1|2` (variabel `--acc-z` teks kecil dan `--acc-zm` teks sedang, dipakai lewat `zoom` pada elemen teks saja), `acc-cb` + `acc-cb-pro|deu|tri` (filter SVG `#acc-cb-*` pada `#game-shell`, dibuat access.js; bar HP lawan bergaris dan lapisan cadangan bertitik), `acc-hc`, `acc-calm` (cermin aturan prefers-reduced-motion yang sudah ada di berkas CSS lain). Mesin membaca `window.Access` secara malas dan tanpa modul ini semua kait menjadi no-op. Kait di game.js, semuanya hanya sisi pemain (`hero`): `startAttack` (Kombo otomatis: pukulan 1 dibuat dengan `queued: 2`), `heroGuard` + `step` (Blok otomatis: serangan dari depan saat di tanah, tidak beraksi, guard belum pecah, masuk jalur blok biasa lalu `hero.autoGuard` menahan sikap blok 0,3 dtk; guard tetap bisa pecah), `step` (`Access.capCooldowns`: skill 1 dan 2 selalu 0, ultimate paling lama 5 dtk), `ultMax` dan pie cooldown (`Access.cdMax` agar bilah ikut batas 5 dtk), `useShopMove` (`shopCd` 0), `passiveRegen` (+2 HP/dtk, aturan 3 dtk tanpa terkena tetap berlaku), `draw`/`drawFlash`/`updateHud` (`Access.motion`: 0 = tanpa getaran layar, sentakan, zoom kamera Bro Wo, kilat sprite, potongan ultimate meluncur). `feel.js` memakai pengali yang sama lewat `motion()`/`calm()` (zoom K.O. dimatikan, kilat K.O. dimatikan, vignette tetap, animasi peringkat kombo berhenti) dan `vs.js` menghentikan hitung naik koin. Saklar "Animasi latar menu" dipadamkan lewat saklar aslinya (menu.js tidak diubah). Hadiah koin dan pencapaian tidak dibedakan saat bantuan aktif. Uji manual cepat di konsol: `Access.set('nocd', true)`, `Access.set('cb', 'deu')`, `Access.set('text', 2)`, `Access.state()`, `Access.reset()`. `precache.js`/`files.json` perlu dibangun ulang oleh integrator agar `access.js` dan `access.css` ikut tersimpan offline.
+- Tutorial singkat (Tahap 4.1): `tutorial.js` (`window.Tutorial`) + `tutorial.css`, dimuat di `index.html` tepat sebelum `menu.js`. Tombol TUTORIAL kecil di pojok kiri atas layar utama (dipasang `Tutorial.decorate(root, screen)`, dipanggil di akhir `render()` menu.js; tidak menambah baris tombol, jadi menu tetap muat di 520 px) memulai `startMatch({mode:'training', level:'easy', stage:'bellora'})` dengan karakter terakhir yang dimainkan (atau Mega Chan) melawan Soloman (atau Mega Chan) sebagai dummy diam. Delapan langkah (`STEPS` di tutorial.js: gerak, lompat, kombo, gerakan perintah, skill, blok dan parry, lempar, ultimate) dideteksi per frame dari `__game.hero` (`state`, `jumps`, `action.type/special`, `blocking`, `cooldowns`, `spCd`, `throwCd`) dan dua kait baru di game.js: `__game.stats()` (`comboHits`, `bestCombo`, `totalDamage`) serta `__game.dummyAttack(slow)` (serangan dasar pertama dummy, hanya di latihan; durasi dikali `slow` supaya pembuka parry terbaca; `index` disetel 3 supaya `cpuFollowUp` tidak merangkai serangan kedua). Hasil serangan dummy (parry, blok, kena) dibaca dari pembungkus `__game.receiveHit` yang dipasang tutorial (hanya mengamati, tidak mengubah hasil). Selesai = +50 koin satu kali (`Meta.addCoins`); tandanya `Meta.save.eggs['code:tutorial']` (awalan `code:` supaya tidak dihitung sebagai kejutan oleh `mystery.js`). Panel `#tut-panel` ada di `#game-shell`, tengah atas di bawah header HUD (letaknya mengikuti tiga ukuran header di style.css); `#announcement` digeser ke bawah panel lewat `body.tut-on` dan `--tut-bottom`. Tutorial berhenti sendiri bila menu utama terbuka (`body.in-menu`). Di `html.touch` teks dan sasaran memakai tombol layar. Belum ada skenario uji otomatis; cek cepat di konsol: `Tutorial.state()`, `Tutorial.start({player:'browo'})`.
- `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`.
- Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache).
- `vs.js` (`window.VS`, `window.ResultView`) + `vs.css` + `content/quotes.js` (`window.QUOTES`: intro/win/lose per karakter): layar VS dan layar hasil. `startMatch({..., vs: true | {label, p2}})` menampilkan layar VS 2,2 dtk (hanya mode versus; latihan tidak); selama itu `vsHold` di game.js menahan `advanceMatch` dan cue 'Round 1' keluar tepat sekali saat VS selesai atau dilewati (klik/Spasi/Enter/Esc setelah 0,38 dtk). `startMatch` tanpa `vs` berperilaku seperti dulu. `menu.js` screen `result` memakai `ResultView.view/mount` (layar lama jadi cadangan); `Meta.award` menyimpan rincian di `Meta.lastAward`; `Meta.nextFight`/`finishRun` memakai VS dan layar hasil yang sama. Pemilih kutipan `VS.quote(id, 'intro'|'win'|'lose')` memakai pengacak kosmetik terpisah. API uji: `__game.vsActive()`, `__game.vsSkip()`. Menu depan yang tersembunyi dijamin hilang oleh `#front-menu[hidden]{display:none!important}` (menu.css); aturan `display` per `data-screen` tidak boleh menimpanya. Layar hasil memfokuskan wadah `.rs` (`tabindex=-1`), bukan tombol, agar Spasi/Enter dari pertarungan tidak langsung memicu MAIN LAGI (Tab tetap menjangkau tombol). `Meta.toast` menahan toast selama `VS.active` dan memunculkannya sesudah VS selesai.
diff --git a/game.js b/game.js
index 0f57121..4d20cd2 100644
--- a/game.js
+++ b/game.js
@@ -2998,6 +2998,18 @@
// shadowFoe() melaporkan apakah pertandingan sekarang melawan Bayangan (uji manual).
{ const baseHit = window.__game.receiveHit; window.__game.receiveHit = (damage, options) => baseHit(foeDamage(damage), options); }
window.__game.shadowFoe = () => shadowFoe;
+ // Tutorial (RENCANA 4.1, tutorial.js). stats() = kait baca kombo dan damage latihan. dummyAttack(slow) = perintah serangan dasar pertama dummy, hanya di latihan
+ // (bukan P2): menghadap pemain, durasi dikali slow supaya pembukanya terbaca untuk latihan parry; index disetel 3 supaya cpuFollowUp tidak merangkai serangan kedua.
+ Object.assign(window.__game, {
+ stats: () => ({ comboHits, bestCombo, totalDamage }),
+ dummyAttack(slow = 1) {
+ if (menuOpen || paused || p2Mode || match?.mode !== 'training' || hero.hp <= 0 || dummy.action || dummy.y < CONFIG.groundY - 1 || ['hurt', 'down', 'recover'].includes(dummy.state)) return false;
+ dummy.facing = hero.x >= dummy.x ? 1 : -1;
+ if (!startEnemyAction('attack', 1)) return false;
+ if (dummy.action) { dummy.action.duration *= Math.max(1, slow); dummy.action.index = 3; }
+ return true;
+ }
+ });
if(F)reset();
if(window.FRONTEND_ENABLED)setMenuOpen(true);
resize(); requestAnimationFrame(tick);
diff --git a/index.html b/index.html
index 0afbd43..e433717 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
Konoha Fighting
-
+
KONOHAFIGHTING
Menyiapkan…
+
diff --git a/menu.js b/menu.js
index 4951584..da2260f 100644
--- a/menu.js
+++ b/menu.js
@@ -75,6 +75,7 @@
const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`
${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH ${won?'VICTORY':'DEFEAT'} ${fighters[winner].name} WINS
${result.playerWins} — ${result.enemyWins}
${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label} CHARACTER SELECT MAIN MENU `;
}
bind();attachBackground();window.Brand?.decorate(root,screen);window.Mystery?.decorate(root,screen,hover,lockedSlots);
+ window.Tutorial?.decorate(root,screen); // tombol TUTORIAL kecil di pojok kiri atas layar utama (tutorial.js)
}
function updatePreview(play=true){
root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover));
diff --git a/tutorial.css b/tutorial.css
new file mode 100644
index 0000000..9e3e582
--- /dev/null
+++ b/tutorial.css
@@ -0,0 +1,67 @@
+/* Tutorial singkat (tutorial.js, RENCANA 4.1): tombol di menu utama, panel instruksi di arena, dan tanda "!" untuk latihan parry.
+ Tinggi minimum yang didukung 520 px (1024x520); ada aturan ringkas untuk jendela pendek dan ponsel lanskap (844x390). */
+
+/* ---- Tombol TUTORIAL di menu utama: pojok kiri atas, di dalam bilah .home-top tetapi di luar alur (absolut, tengah vertikal), sehingga
+ tinggi bilah dan letak tombol-tombol menu tidak berubah sedikit pun (menu tetap muat di 520 px) ---- */
+#front-menu .home-top{position:relative}
+#front-menu .tut-entry{position:absolute;left:4.5%;top:50%;z-index:1;display:inline-flex;align-items:center;gap:8px;min-height:26px;padding:3px 14px 3px 10px;background:#102b37c4;border:1px solid #d7b87d77;border-left:3px solid #d7b87d;color:#f6eedb;font:700 13px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:2.5px;cursor:pointer;transform:translateY(-50%) skewX(-7deg);transition:background .14s,border-color .14s,transform .14s}
+#front-menu .tut-entry>*{transform:skewX(7deg)}
+#front-menu .tut-entry i{font-style:normal;font-size:11px;color:#d7b87d}
+#front-menu .tut-entry:not(.done){animation:tut-nudge 2.2s ease-in-out infinite}
+#front-menu .tut-entry:hover,#front-menu .tut-entry:focus-visible{background:#f0d49d;color:#122837;border-color:#fff0c8;transform:translateY(-50%) skewX(-7deg) translateX(4px);animation:none}
+#front-menu .tut-entry:hover i,#front-menu .tut-entry:focus-visible i{color:#122837}
+#front-menu .tut-entry.done{border-left-color:#8dffa8}#front-menu .tut-entry.done i{color:#8dffa8}
+@keyframes tut-nudge{0%,100%{box-shadow:0 0 0 #d7b87d00}50%{box-shadow:0 0 14px #d7b87d88}}
+html.touch #front-menu .tut-entry{min-height:32px}
+@media(max-width:800px){#front-menu .tut-entry{left:6%}}
+@media(max-height:420px){#front-menu .tut-entry{min-height:22px;padding:2px 10px 2px 8px;font-size:11px;letter-spacing:2px}html.touch #front-menu .tut-entry{min-height:28px}}
+
+/* ---- Panel instruksi di arena: tengah atas, di bawah header HUD dan tombol jeda; letaknya mengikuti tiga ukuran header di style.css ---- */
+#tut-panel{--tt:18px;--tx:15px;--gl:14px;--kb:12px;position:absolute;left:50%;top:156px;z-index:4;width:min(540px,calc(100vw - 340px));transform:translateX(-50%);box-sizing:border-box;padding:8px 14px 10px;color:#fff8e8;background:linear-gradient(180deg,#0d2432f0,#0a1b27ea);border:1px solid #e6c78f55;border-top:3px solid #e6c78f;box-shadow:0 8px 26px #0009;pointer-events:none;font-family:Rajdhani,'Arial Narrow',sans-serif}
+#tut-panel.fin{border-top-color:#8dffa8}
+.tut-head{display:flex;align-items:center;gap:10px;min-height:26px}
+.tut-step{flex:none;font-size:11px;font-weight:700;letter-spacing:2px;color:#e6c78f;white-space:nowrap}
+.tut-title{flex:1;min-width:0;margin:0;font-size:var(--tt);line-height:1.1;font-weight:700;font-style:italic;letter-spacing:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:2px 2px 0 #13232c}
+#tut-panel.fin .tut-title{color:#8dffa8}
+.tut-actions{flex:none;display:flex;gap:6px}
+.tut-btn{pointer-events:auto;padding:3px 9px;font:700 11px/1.2 Rajdhani,Arial,sans-serif;letter-spacing:1.4px;color:#e6ecea;background:#13303d;border:1px solid #7dada177;cursor:pointer}
+.tut-btn:hover,.tut-btn:focus-visible{background:#e6c78f;color:#102734;border-color:#fff0c8}
+.tut-free{background:#e6c78f;color:#102734;border-color:#fff0c8}
+.tut-btn[hidden],.tut-goals[hidden]{display:none}
+.tut-text{margin:3px 0 6px;min-height:1.25em;font-size:var(--tx);line-height:1.25;color:#d6e4e6}
+.tut-text.hint{color:#ffd36a}.tut-text.bad{color:#ff9d8f}.tut-text.good{color:#8dffa8}
+.tut-goals{display:flex;flex-wrap:wrap;gap:6px 8px;margin:0;padding:0;list-style:none}
+.tut-goal{display:flex;align-items:center;gap:7px;min-height:26px;padding:2px 10px 2px 6px;font-size:var(--gl);font-weight:700;letter-spacing:.4px;background:#102b37c4;border:1px solid #7dada144;border-left:3px solid #7fa69877}
+.tut-goal .box{flex:none;width:16px;height:16px;display:grid;place-items:center;font-style:normal;font-size:12px;line-height:1;color:transparent;border:2px solid #9fb3c4}
+.tut-goal.done{background:#12362bd6;border-left-color:#8dffa8}
+.tut-goal.done .box{background:#8dffa8;border-color:#8dffa8;color:#0c2230}
+.tut-goal.done.new .box{animation:tut-pop .3s ease-out}
+.tut-goal.done .lab{color:#b9ffcd}
+.tut-goal .keys{display:flex;align-items:center;gap:3px}
+.tut-key{display:inline-block;min-width:20px;padding:1px 6px;font:700 var(--kb)/18px Rajdhani,Arial,sans-serif;text-align:center;background:#ecddbb;color:#102731;box-shadow:0 2px 0 #8d7f5f}
+.tut-goal .plus{font-size:12px;color:#9fb3c4}
+.tut-goal .lab small{font-size:.85em;font-weight:500;letter-spacing:0;color:#ffd36a}
+.tut-bar{position:absolute;left:0;right:0;bottom:0;height:3px;background:#ffffff1c}
+.tut-bar i{display:block;height:100%;width:0;background:linear-gradient(90deg,#86d9c4,#e0bf85);transition:width .35s ease-out}
+.tut-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+@keyframes tut-pop{0%{transform:scale(.4)}70%{transform:scale(1.3)}100%{transform:scale(1)}}
+/* Pengumuman besar (UPPERCUT!, PARRY!, ...) turun ke bawah panel supaya tidak tertutup. --tut-bottom diisi tutorial.js. */
+body.tut-on #announcement{top:calc(var(--tut-bottom,160px) + 6px)}
+
+/* ---- Tanda "!" di atas kepala dummy, 0,3 dtk sebelum pukulannya kena (ketuk L sekarang). Posisi dan skala diisi tutorial.js ---- */
+.tut-cue{position:absolute;z-index:4;left:0;top:0;width:46px;display:none;transform:translate(-50%,-100%) scale(var(--cs,1));transform-origin:50% 100%;text-align:center;pointer-events:none}
+.tut-cue.on{display:block}
+.tut-cue b{display:block;width:46px;height:46px;border-radius:50%;font:700 34px/46px Rajdhani,Arial,sans-serif;color:#2a1a00;background:#ffd36a;box-shadow:0 0 0 4px #ff6a5acc,0 0 24px #ffb15a;animation:tut-cue .22s ease-in-out infinite alternate}
+.tut-cue small{display:block;margin-top:3px;font:700 13px/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#fff3d4;text-shadow:0 1px 3px #000}
+@keyframes tut-cue{from{transform:scale(.92)}to{transform:scale(1.08)}}
+
+/* ---- Ukuran: layar lebar, header besar, header ringkas, jendela pendek (<= 500 px) ---- */
+@media(min-width:1200px) and (min-height:640px){#tut-panel{--tt:21px;--tx:17px;--gl:16px;--kb:13px;width:min(660px,calc(100vw - 560px));padding:10px 16px 12px}.tut-step{font-size:12px}.tut-btn{padding:4px 11px;font-size:12px}.tut-goal{min-height:30px}}
+@media(min-width:1600px){#tut-panel{top:170px}}
+@media(max-width:950px){#tut-panel{top:137px;width:min(520px,calc(100vw - 300px))}}
+@media(max-width:620px){#tut-panel{top:118px;width:calc(100vw - 24px)}}
+@media(max-height:500px) and (min-width:620px){#tut-panel{--tt:15px;--tx:13px;--gl:12px;--kb:11px;top:118px;width:min(520px,calc(100vw - 340px));padding:6px 12px 8px}.tut-head{min-height:22px}.tut-text{margin:2px 0 5px}.tut-goal{min-height:22px;padding:1px 8px 1px 5px}.tut-key{line-height:16px}.tut-btn{padding:2px 7px;font-size:10px}}
+html.touch .tut-btn{padding:5px 10px}
+@media(max-height:500px){html.touch .tut-btn{padding:4px 9px}}
+
+@media(prefers-reduced-motion:reduce){#front-menu .tut-entry,.tut-goal.done.new .box,.tut-cue b{animation:none!important}.tut-bar i{transition:none}}
diff --git a/tutorial.js b/tutorial.js
new file mode 100644
index 0000000..a6c8dec
--- /dev/null
+++ b/tutorial.js
@@ -0,0 +1,301 @@
+/* Tutorial singkat (RENCANA 4.1). Tombol TUTORIAL di menu utama (pojok kiri atas, dipasang decorate() yang dipanggil menu.js di akhir render())
+ memulai latihan terpandu di arena latihan: karakter terakhir yang dimainkan pemain (atau Mega Chan), lawan = dummy diam yang hanya
+ menyerang atas perintah tutorial. Delapan langkah berurutan (gerak, lompat, kombo, gerakan perintah, skill, blok dan parry, lempar,
+ ultimate); tiap langkah punya satu atau dua sasaran yang dideteksi sendiri dari keadaan mesin dan dicentang, lalu pindah otomatis.
+ Panel instruksi (#tut-panel) ada di #game-shell, di bawah header HUD; gaya di tutorial.css. Tombol LEWATI melewati langkah, KELUAR kembali
+ ke menu utama. Selesai = +50 koin satu kali (Meta.addCoins); penandanya di Meta.save.eggs['code:tutorial'] (awalan "code:" supaya
+ tidak dihitung sebagai kejutan oleh slot misteri, lihat mystery.js).
+ Deteksi (tanpa mengubah aturan permainan): hero.state/jumps/action/blocking/cooldowns dan dummy.action dibaca langsung dari __game;
+ kombo dan damage lewat __game.stats(). Langkah blok/parry memicu serangan dummy lewat __game.dummyAttack(slow): pembukanya
+ diperlambat dan tanda "!" muncul di atas dummy 0,3 dtk sebelum kena; hasilnya (parry, blok, kena) dibaca dari pembungkus
+ __game.receiveHit (pola sama dengan pembungkus Bayangan di game.js; hanya mengamati, tidak mengubah hasil). Di layar sentuh teks dan tombol
+ yang disebut memakai tombol layar. Semua fungsi publik aman: bila ada galat, tutorial berhenti dan permainan jalan seperti biasa.
+ API: Tutorial.start({player?}), stop(), skip(), decorate(root, screen), done(), active, state(). Uji manual cepat: tombol TUTORIAL di menu
+ utama, lalu Tutorial.state() di konsol. */
+(() => {
+ 'use strict';
+ const FLAG = 'code:tutorial', COINS = 50;
+ const HOLD = 1.1; // jeda (detik) setelah langkah selesai sebelum pindah otomatis
+ const GAP = 1.7; // jeda antar serangan dummy di langkah blok dan parry
+ const REACH = 150; // serangan dummy hanya dilepas bila pemain sedekat ini (jangkauan serangannya sekitar 160 px)
+ const $ = s => document.querySelector(s);
+ const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const touch = () => document.documentElement.classList.contains('touch');
+ const game = () => window.__game;
+ const done = () => !!window.Meta?.save?.eggs?.[FLAG];
+ const skillName = slot => ($(`.skill-card[data-action="${slot}"] .skill-name`)?.textContent || '').trim();
+ const side = c => (c.dummy.x >= c.hero.x ? { name: 'kanan', kb: 'D', ts: '▶' } : { name: 'kiri', kb: 'A', ts: '◀' });
+
+ // c = konteks tiap frame { g, hero, dummy, st (kombo/damage), dt, fwd, ok(id), say(teks, nada, detik) }; t = catatan sementara per langkah.
+ // text: { kb, ts } atau fungsi (c, sentuh) -> teks. goals[].kb/ts: daftar tombol (atau fungsi c -> daftar; '+' = tanda tambah),
+ // label: teks atau fungsi, wait: fungsi c -> sisa cooldown (detik) yang ditampilkan di sasaran yang belum selesai.
+ // range: jarak mendatar ke dummy (px) di mana petunjuk "dekati dummy" muncul; face: petunjuk "hadapkan karakter" juga aktif.
+ const STEPS = [
+ { id: 'move', title: 'GERAK',
+ text: { kb: 'Tahan A untuk ke kiri dan D untuk ke kanan. Ketuk dua kali untuk lari.', ts: 'Tahan panah ◀ untuk ke kiri dan ▶ untuk ke kanan. Ketuk dua kali untuk lari.' },
+ goals: [{ id: 'left', kb: ['A'], ts: ['◀'], label: 'Ke kiri' }, { id: 'right', kb: ['D'], ts: ['▶'], label: 'Ke kanan' }],
+ init: (c, t) => { t.x = c.hero.x; t.l = 0; t.r = 0; },
+ tick: (c, t) => {
+ const h = c.hero, dx = h.x - t.x; t.x = h.x;
+ if (h.grounded && (h.state === 'walk' || h.state === 'run')) { if (dx < 0) t.l -= dx; else t.r += dx; }
+ if (t.l >= 90) c.ok('left');
+ if (t.r >= 90) c.ok('right');
+ } },
+ { id: 'jump', title: 'LOMPAT',
+ text: { kb: 'Tekan W untuk lompat. Tekan W lagi saat di udara untuk lompat ganda.', ts: 'Ketuk panah ▲ untuk lompat. Ketuk lagi saat di udara untuk lompat ganda.' },
+ goals: [{ id: 'jump', kb: ['W'], ts: ['▲'], label: 'Lompat' }, { id: 'double', kb: ['W', 'W'], ts: ['▲', '▲'], label: 'Lompat ganda' }],
+ tick: c => { const h = c.hero; if (h.jumps >= 1 && !h.grounded) c.ok('jump'); if (h.jumps >= 2) { c.ok('jump'); c.ok('double'); } } },
+ { id: 'punch', title: 'PUKULAN DAN KOMBO', range: 175, face: true,
+ text: { kb: 'Dekati dummy, lalu tekan SPASI 3 kali berturut-turut untuk kombo 3 pukulan.', ts: 'Dekati dummy, lalu ketuk tombol SERANG (besar) 3 kali berturut-turut.' },
+ goals: [{ id: 'punch', kb: ['SPASI'], ts: ['SERANG'], label: 'Pukulan dasar' }, { id: 'combo', kb: ['SPASI ×3'], ts: ['SERANG ×3'], label: 'Kombo 3 pukulan' }],
+ init: (c, t) => { t.base = c.st.comboHits; },
+ tick: (c, t) => {
+ const a = c.hero.action, n = c.st.comboHits; if (n < t.base) t.base = 0; // kombo lama habis: hitung dari nol
+ if (a?.type === 'attack' && !a.special) c.ok('punch');
+ if (n - t.base >= 1) c.ok('punch');
+ if (n - t.base >= 3) { c.ok('punch'); c.ok('combo'); }
+ } },
+ { id: 'command', title: 'GERAKAN PERINTAH', range: 230,
+ text: { kb: '↓↓+SPASI = uppercut, ↓→+SPASI = tinju maju. ↓ = tombol S, → = arah hadap.', ts: 'Ketuk UP untuk uppercut dan →P untuk tinju maju (tombol kecil di kanan).' },
+ goals: [
+ { id: 'uppercut', kb: ['S', 'S', '+', 'SPASI'], ts: ['UP'], label: 'Uppercut', wait: c => c.hero.spCd },
+ { id: 'lunge', kb: c => ['S', c.fwd, '+', 'SPASI'], ts: ['→P'], label: 'Tinju maju', wait: c => c.hero.spCd }],
+ tick: c => { const s = c.hero.action?.special; if (s === 'uppercut') c.ok('uppercut'); if (s === 'lunge') c.ok('lunge'); } },
+ { id: 'skill', title: 'SKILL',
+ text: { kb: 'Tekan I untuk skill 1 dan O untuk skill 2. Cooldown ada di kartu skill.', ts: 'Ketuk tombol skill 1 dan skill 2 (bulat kecil di kiri-atas tombol serang).' },
+ goals: [
+ { id: 's1', kb: ['I'], ts: ['SKILL 1'], label: () => skillName('skill1') || 'Skill 1', wait: c => c.hero.cooldowns?.skill1 },
+ { id: 's2', kb: ['O'], ts: ['SKILL 2'], label: () => skillName('skill2') || 'Skill 2', wait: c => c.hero.cooldowns?.skill2 }],
+ tick: c => { const k = c.hero.action?.type; if (k === 'skill1') c.ok('s1'); if (k === 'skill2') c.ok('s2'); } },
+ { id: 'guard', title: 'BLOK DAN PARRY', range: REACH,
+ text: (c, ts) => S.goals.block
+ ? (ts ? 'Sekarang KETUK BLOK sekali, tepat sebelum pukulan kena (saat tanda ! muncul).' : 'Sekarang KETUK L sekali, tepat sebelum pukulan kena (saat tanda ! muncul).')
+ : (ts ? 'Dummy akan menyerang. TAHAN tombol BLOK sampai pukulannya kena.' : 'Dummy akan menyerang. TAHAN L untuk memblok sampai pukulannya kena.'),
+ goals: [{ id: 'block', kb: ['L (tahan)'], ts: ['BLOK (tahan)'], label: 'Blok' }, { id: 'parry', kb: ['L (ketuk)'], ts: ['BLOK (ketuk)'], label: 'Parry' }],
+ init: (c, t) => { t.hold = 0; t.next = S.clock + GAP; t.acting = false; },
+ tick: (c, t) => {
+ const h = c.hero, d = c.dummy, first = !S.goals.block;
+ t.hold = h.blocking ? t.hold + c.dt : 0; if (t.hold >= 2.5) c.ok('block'); // cadangan: menahan L lama; yang utama blok sungguhan (hasil 'block')
+ const hit = S.hit; S.hit = null; // hasil serangan dummy terakhir, dicatat pembungkus receiveHit
+ if (hit === 'parry') { c.ok('parry'); c.say('PARRY! Tepat waktu.', 'good', 2); }
+ else if (hit === 'block') { if (first) { c.ok('block'); c.say('Blok berhasil! Sekarang coba parry.', 'good', 2.4); } else c.say('Itu blok biasa. Untuk parry: lepas L, lalu ketuk sekali tepat sebelum kena.', 'hint', 3); }
+ else if (hit === 'hit') c.say('Kena! Tahan L untuk memblok, atau ketuk L tepat sebelum kena.', 'bad', 2.6);
+ if (typeof c.g.dummyAttack !== 'function' && !t.warned) { t.warned = true; c.say('Serangan dummy tidak tersedia di build ini. Tekan LEWATI.', 'bad', 8); }
+ const a = d.action, lead = .14 + .16 * (c.g.passiveMod ? c.g.passiveMod(h, 'parry', 1) : 1); // tanda muncul sedikit lebih awal dari jendela parry (0,16 dtk x pengali pasif)
+ if (a) t.acting = true; else if (t.acting) { t.acting = false; t.next = S.clock + GAP; }
+ cueAt(a && !a.fired && a.type === 'attack' && S.goals.block && !S.goals.parry && a.duration * a.hitAt - a.t <= lead ? d : null);
+ // Blok: pembuka agak lambat; parry: lebih lambat lagi supaya bisa dibaca (jendela parry hanya 0,16 dtk).
+ if (!a && S.clock >= t.next && typeof c.g.dummyAttack === 'function' && Math.abs(h.x - d.x) <= REACH && h.grounded && !h.action && h.hurtTime <= 0 && h.invuln <= 0 && c.g.dummyAttack(S.goals.block ? 4 : 3)) t.acting = true;
+ } },
+ { id: 'throw', title: 'LEMPAR', range: REACH,
+ text: { kb: 'Berdiri mepet di depan dummy (di tanah) lalu tekan J. Lemparan menembus blok.', ts: 'Berdiri mepet di depan dummy lalu ketuk LEMPAR. Lemparan menembus blok.' },
+ goals: [{ id: 'throw', kb: ['J'], ts: ['LEMPAR'], label: 'Lempar dummy', wait: c => c.hero.throwCd }],
+ init: (c, t) => { t.tot = c.st.totalDamage; t.seen = false; t.hit = false; },
+ tick: (c, t) => {
+ const a = c.hero.action, tot = c.st.totalDamage;
+ if (a?.special === 'throw') { t.seen = true; if (tot > t.tot) { t.hit = true; c.ok('throw'); } }
+ else if (t.seen) { if (!t.hit) c.say('Meleset. Berdiri mepet di depan dummy, di tanah, lalu tekan J lagi.', 'bad', 3); t.seen = t.hit = false; }
+ t.tot = tot;
+ } },
+ { id: 'ultimate', title: 'ULTIMATE',
+ text: { kb: 'Tekan P untuk ultimate, serangan pamungkas dengan cooldown panjang.', ts: 'Ketuk tombol ultimate (bulat, paling atas di busur tombol serang).' },
+ goals: [{ id: 'ult', kb: ['P'], ts: ['ULTIMATE'], label: () => skillName('ultimate') || 'Ultimate', wait: c => c.hero.cooldowns?.ultimate }],
+ init: (c, t) => { t.cd = c.hero.cooldowns?.ultimate || 0; },
+ tick: (c, t) => { const cd = c.hero.cooldowns?.ultimate || 0; if (c.hero.action?.type === 'ultimate' || (t.cd <= 0 && cd > 0)) c.ok('ult'); t.cd = cd; } }
+ ];
+
+ let S = null, raf = 0, lastTs = 0, hooked = false, panel = null, cue = null, ui = null;
+
+ // ---- menu utama: tombol kecil di pojok kiri atas ----
+ function decorate(root, screen) {
+ try {
+ if (!root || screen !== 'home') return;
+ const slot = root.querySelector('.home-top'); if (!slot || slot.querySelector('.tut-entry')) return;
+ const ok = done(), b = document.createElement('button');
+ b.type = 'button'; b.className = 'tut-entry' + (ok ? ' done' : ''); b.dataset.tut = 'start';
+ b.setAttribute('aria-label', ok ? 'Tutorial, sudah selesai. Ulangi tutorial terpandu' : 'Mulai tutorial terpandu di arena latihan');
+ b.innerHTML = `${ok ? '✓' : '▶'} TUTORIAL `;
+ b.onclick = () => { game()?.menuSound?.('confirm'); start(); };
+ slot.append(b);
+ } catch (e) { console.error(e); }
+ }
+
+ // ---- mulai dan berhenti ----
+ function pick(opt) {
+ const roster = Object.keys(window.FIGHTERS || {}); if (window.Browo && window.BROWO_MANIFEST) roster.unshift('browo');
+ let me = opt?.player; if (!roster.includes(me)) { try { me = game().snapshot().selectedCharacter; } catch (_) { me = null; } }
+ if (!roster.includes(me)) me = roster.includes('megachan') ? 'megachan' : roster[0];
+ // Lawan harus petarung berbasis data (window.FIGHTERS): serangan dasarnya dipakai dummyAttack.
+ const foe = ['soloman', 'megachan', 'gufron'].find(id => id !== me && window.FIGHTERS?.[id]) || roster.find(id => id !== me && window.FIGHTERS?.[id]) || me;
+ return { player: me, enemy: foe };
+ }
+ function start(opt) {
+ try {
+ const G = game(); if (!G) return false;
+ stop();
+ if (typeof G.stats !== 'function' || typeof G.dummyAttack !== 'function') console.warn('Tutorial: kait __game.stats / dummyAttack tidak ada di game.js; deteksi kombo dan latihan parry tidak jalan.');
+ const f = pick(opt);
+ window.Meta?.cancelRun?.();
+ if (!G.startMatch({ player: f.player, enemy: f.enemy, stage: 'bellora', level: 'easy', mode: 'training' })) { window.Meta?.toast?.('Aset belum siap. Tunggu sebentar lalu coba lagi.'); return false; }
+ hook();
+ S = { i: 0, phase: 'run', goals: {}, t: {}, skipped: [], seen: {}, say: null, hit: null, fresh: false, clock: 0, doneAt: 0, reward: 0, c: null, key: '' };
+ build(); initStep();
+ document.body.classList.add('tut-on'); addEventListener('resize', fit);
+ $('#arena')?.focus({ preventScroll: true });
+ lastTs = 0; raf = requestAnimationFrame(loop);
+ return true;
+ } catch (e) { console.error(e); stop(); return false; }
+ }
+ function stop() {
+ if (raf) cancelAnimationFrame(raf); raf = 0; S = null;
+ removeEventListener('resize', fit);
+ panel?.remove(); cue?.remove(); panel = cue = ui = null;
+ document.body.classList.remove('tut-on'); document.body.style.removeProperty('--tut-bottom');
+ }
+ function exit() { stop(); window.FrontEnd?.home?.(); }
+ function skip() {
+ if (!S || S.phase === 'finish') return;
+ if (S.phase === 'run') S.skipped.push(S.i);
+ next();
+ }
+
+ // Mengamati hasil serangan dummy ke pemain (parry, blok, kena). Hanya membaca keadaan sebelum dan sesudah; hasil asli tidak diubah.
+ function hook() {
+ const G = game(); if (hooked || typeof G?.receiveHit !== 'function') return; hooked = true;
+ const base = G.receiveHit;
+ G.receiveHit = function (damage, options) {
+ const h = G.hero, pre = { parry: h.parry > 0 && !h.action, block: !!h.blocking && !h.action };
+ const ok = base.apply(this, arguments);
+ try { if (ok && S && S.phase === 'run' && STEPS[S.i].id === 'guard') S.hit = pre.parry && !(h.parry > 0) ? 'parry' : pre.block && h.hurtTime <= 0 ? 'block' : h.hurtTime > 0 ? 'hit' : null; } catch (_) { /* hanya pengamat */ }
+ return ok;
+ };
+ }
+
+ // ---- alur langkah ----
+ function ctx(dt) {
+ const G = game(), hero = G.hero, dummy = G.dummy;
+ return {
+ g: G, hero, dummy, dt, fwd: hero.facing > 0 ? 'D' : 'A',
+ st: typeof G.stats === 'function' ? G.stats() : { comboHits: 0, bestCombo: 0, totalDamage: 0 },
+ ok: id => { if (!S.goals[id]) { S.goals[id] = true; S.fresh = true; } },
+ say: (text, tone = 'hint', secs = 2.4) => { S.say = { text, tone, until: S.clock + secs }; }
+ };
+ }
+ function initStep() {
+ S.goals = {}; S.t = {}; S.say = null; S.hit = null; S.fresh = false; S.phase = 'run'; cueAt(null);
+ STEPS[S.i].init?.(ctx(0), S.t);
+ sr(`Langkah ${S.i + 1} dari ${STEPS.length}: ${STEPS[S.i].title}`);
+ paint(true);
+ }
+ function next() {
+ cueAt(null);
+ if (++S.i >= STEPS.length) { finish(); return; }
+ initStep();
+ }
+ function finish() {
+ S.phase = 'finish'; S.i = STEPS.length - 1; S.reward = reward() ? COINS : 0;
+ game()?.menuSound?.('pick'); sr('Tutorial selesai'); paint(true);
+ }
+ function reward() {
+ try {
+ const M = window.Meta, save = M?.save; if (!M || !save) return false;
+ if (!save.eggs || typeof save.eggs !== 'object') save.eggs = {};
+ if (save.eggs[FLAG]) return false;
+ save.eggs[FLAG] = 1; M.addCoins(COINS); M.toast?.(`TUTORIAL SELESAI +${COINS} koin`, 'ach');
+ return true;
+ } catch (e) { console.error(e); return false; }
+ }
+ // Menjaga latihan tetap nyaman: HP pemain dan dummy tidak habis oleh percobaan berulang.
+ function keep(c) {
+ const h = c.hero, d = c.dummy;
+ if (h.hp > 0 && h.hp < 90) h.hp = c.g.balance?.heroMax || 200;
+ if (!d.ko && d.damage >= 100 && d.state === 'idle') d.damage = 0;
+ }
+ function loop(ts) {
+ raf = requestAnimationFrame(loop);
+ if (!S) return;
+ const dt = lastTs ? Math.min(.1, (ts - lastTs) / 1000) : 0; lastTs = ts;
+ try { update(dt); } catch (e) { console.error(e); stop(); }
+ }
+ function update(dt) {
+ if (document.body.classList.contains('in-menu') || !game()) { stop(); return; }
+ if (document.hidden || $('#pause-panel')?.open || $('#settings-panel')?.open) return; // jeda: waktu tutorial ikut berhenti
+ S.clock += dt;
+ const c = ctx(dt); S.c = c; keep(c);
+ if (S.phase === 'run') {
+ const st = STEPS[S.i]; st.tick?.(c, S.t);
+ if (st.goals.every(x => S.goals[x.id])) { S.phase = 'done'; S.doneAt = S.clock; cueAt(null); c.g.menuSound?.('confirm'); sr(`Langkah ${S.i + 1} selesai`); }
+ else if (S.fresh) c.g.menuSound?.('blink');
+ } else if (S.phase === 'done' && S.clock - S.doneAt >= HOLD) next();
+ S.fresh = false;
+ paint(false);
+ }
+
+ // ---- panel ----
+ function build() {
+ const host = $('#game-shell') || document.body;
+ panel = document.createElement('section'); panel.id = 'tut-panel'; panel.setAttribute('role', 'region'); panel.setAttribute('aria-label', 'Tutorial terpandu');
+ panel.innerHTML = '
LEWATI ▸ LATIHAN BEBAS KELUAR ✕
';
+ ui = { bar: panel.querySelector('.tut-bar i'), step: panel.querySelector('.tut-step'), title: panel.querySelector('.tut-title'), text: panel.querySelector('.tut-text'), goals: panel.querySelector('.tut-goals'), skip: panel.querySelector('.tut-skip'), free: panel.querySelector('.tut-free'), exit: panel.querySelector('.tut-exit'), sr: panel.querySelector('.tut-sr') };
+ // Fokus tetap di kanvas: tombol yang sedang fokus akan menelan SPASI (serangan) sebagai klik.
+ const back = () => $('#arena')?.focus({ preventScroll: true });
+ for (const [b, fn] of [[ui.skip, () => { skip(); back(); }], [ui.free, () => { stop(); back(); }], [ui.exit, exit]]) { b.onmousedown = e => e.preventDefault(); b.onclick = fn; }
+ cue = document.createElement('div'); cue.className = 'tut-cue'; cue.setAttribute('aria-hidden', 'true'); cue.innerHTML = '! L ';
+ host.append(panel, cue);
+ }
+ function sr(text) { if (ui) { ui.sr.textContent = ''; ui.sr.textContent = text; } }
+ // Tanda "!" di atas kepala dummy: koordinat dunia -> layar dengan rumus yang sama dengan resize() di game.js.
+ function cueAt(actor) {
+ if (!cue) return;
+ if (!actor) { cue.classList.remove('on'); return; }
+ const vw = innerWidth, vh = innerHeight, s = Math.min(vw / 1280, vh / 720), ox = (vw - 1280 * s) / 2, oy = vh * .815 - game().config.groundY * s;
+ cue.style.left = Math.round(ox + actor.x * s) + 'px'; cue.style.top = Math.round(oy + (actor.y - 185) * s) + 'px';
+ cue.style.setProperty('--cs', String(Math.max(.7, Math.min(1.25, s * 1.15)))); cue.classList.add('on');
+ }
+ function fit() { if (panel) document.body.style.setProperty('--tut-bottom', Math.round(panel.getBoundingClientRect().bottom) + 'px'); }
+ // Teks yang tampil: pesan sementara > petunjuk jarak/arah > teks langkah.
+ function message(c, st, base) {
+ if (S.say && S.clock < S.say.until) return S.say;
+ if (S.phase === 'done') return { text: S.i === STEPS.length - 1 ? 'Bagus! Tutorial hampir selesai…' : 'Bagus! Lanjut ke langkah berikutnya…', tone: 'good' };
+ if (st.range) {
+ const gap = c.dummy.x - c.hero.x, d = side(c), key = touch() ? d.ts : d.kb;
+ if (Math.abs(gap) > st.range) return { text: `Jauh dari dummy. Jalan ke ${d.name} (${key}).`, tone: 'hint' };
+ if (st.face && gap * c.hero.facing < -8) return { text: `Hadapkan karaktermu ke dummy: tekan ${key}.`, tone: 'hint' };
+ }
+ return { text: base, tone: '' };
+ }
+ function paint(force) {
+ if (!S || !ui) return;
+ const c = S.c || ctx(0), ts = touch(), fin = S.phase === 'finish', st = STEPS[S.i];
+ let v;
+ if (fin) {
+ const n = S.skipped.length, tip = ' Tekan Esc kapan saja untuk daftar gerakan.';
+ v = { step: 'SELESAI', title: 'TUTORIAL SELESAI', tone: 'good', pct: 100, goals: [],
+ text: (S.reward ? `Semua dasar sudah kamu coba. Hadiah +${COINS} koin masuk ke dompetmu.` : 'Semua dasar sudah kamu coba. Hadiah koin tutorial sudah pernah kamu ambil.') + (n ? ` ${n} langkah dilewati; ulangi dari menu kapan saja.` : tip) };
+ } else {
+ const base = typeof st.text === 'function' ? st.text(c, ts) : (ts ? st.text.ts : st.text.kb), m = message(c, st, base);
+ v = { step: `LANGKAH ${S.i + 1}/${STEPS.length}`, title: st.title, tone: m.tone, text: m.text, pct: (S.i + (S.phase === 'done' ? 1 : 0)) / STEPS.length * 100,
+ goals: st.goals.map(x => {
+ const k = ts ? x.ts : x.kb, w = typeof x.wait === 'function' ? Math.ceil(x.wait(c) || 0) : 0, ok = !!S.goals[x.id];
+ return { id: S.i + x.id, keys: typeof k === 'function' ? k(c) : k, label: typeof x.label === 'function' ? x.label(c) : x.label, ok, wait: ok ? 0 : w };
+ }) };
+ }
+ const key = JSON.stringify([v.step, v.title, v.tone, v.text, v.goals]);
+ if (!force && key === S.key) return;
+ S.key = key;
+ ui.step.textContent = v.step; ui.title.textContent = v.title; ui.text.textContent = v.text; ui.text.className = 'tut-text' + (v.tone ? ' ' + v.tone : '');
+ ui.bar.style.width = v.pct + '%'; panel.classList.toggle('fin', fin);
+ ui.skip.hidden = fin; ui.free.hidden = !fin; ui.exit.textContent = fin ? 'KE MENU' : 'KELUAR ✕';
+ ui.goals.hidden = fin;
+ // Animasi centang hanya untuk sasaran yang baru selesai (S.seen), bukan tiap panel digambar ulang.
+ ui.goals.innerHTML = v.goals.map(x => { const fresh = x.ok && !S.seen[x.id]; if (x.ok) S.seen[x.id] = 1; return `✓ ${x.keys.map(k => (k === '+' ? '+ ' : `${esc(k)} `)).join('')} ${esc(x.label)}${x.wait ? ` tunggu ${x.wait} dtk ` : ''} ${x.ok ? ' selesai ' : ''} `; }).join('');
+ fit();
+ }
+
+ window.Tutorial = {
+ start, stop, skip, decorate, done,
+ steps: STEPS.map(s => s.id),
+ get active() { return !!S; },
+ state: () => (S ? { step: S.i, id: STEPS[S.i]?.id, phase: S.phase, goals: { ...S.goals }, skipped: [...S.skipped], clock: +S.clock.toFixed(2), reward: S.reward } : null)
+ };
+})();