Gabung wf/t4-tutorial: tutorial terpandu
This commit is contained in:
commit
a946bd3e89
|
|
@ -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 `<head>` 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`: `<dialog>` 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`.
|
- Tata letak sentuh yang bisa diatur (RENCANA 4.6): `touchlayout.js` + `touchlayout.css`, dimuat `defer` di `<head>` 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`: `<dialog>` 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 `<head>` 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`).
|
- 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 `<head>` 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 `<details>`, karena penangan tombol game mencegat Spasi pada `<summary>`), 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 `<html>`: `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.
|
- 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 `<details>`, karena penangan tombol game mencegat Spasi pada `<summary>`), 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 `<html>`: `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`.
|
- `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/<id>-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).
|
- Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-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.
|
- `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.
|
||||||
|
|
|
||||||
12
game.js
12
game.js
|
|
@ -2998,6 +2998,18 @@
|
||||||
// shadowFoe() melaporkan apakah pertandingan sekarang melawan Bayangan (uji manual).
|
// shadowFoe() melaporkan apakah pertandingan sekarang melawan Bayangan (uji manual).
|
||||||
{ const baseHit = window.__game.receiveHit; window.__game.receiveHit = (damage, options) => baseHit(foeDamage(damage), options); }
|
{ const baseHit = window.__game.receiveHit; window.__game.receiveHit = (damage, options) => baseHit(foeDamage(damage), options); }
|
||||||
window.__game.shadowFoe = () => shadowFoe;
|
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(F)reset();
|
||||||
if(window.FRONTEND_ENABLED)setMenuOpen(true);
|
if(window.FRONTEND_ENABLED)setMenuOpen(true);
|
||||||
resize(); requestAnimationFrame(tick);
|
resize(); requestAnimationFrame(tick);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="id">
|
<html lang="id">
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
|
||||||
<link rel="manifest" href="manifest.webmanifest"><link rel="icon" type="image/png" sizes="192x192" href="assets/pwa/icon-192.png"><link rel="apple-touch-icon" href="assets/pwa/apple-touch-icon.png"><link rel="stylesheet" href="touchlayout.css"><script src="pwa.js"></script><script src="touchlayout.js" defer></script>
|
<link rel="manifest" href="manifest.webmanifest"><link rel="icon" type="image/png" sizes="192x192" href="assets/pwa/icon-192.png"><link rel="apple-touch-icon" href="assets/pwa/apple-touch-icon.png"><link rel="stylesheet" href="touchlayout.css"><script src="pwa.js"></script><script src="touchlayout.js" defer></script>
|
||||||
<link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"><link rel="stylesheet" href="mystery.css"><link rel="stylesheet" href="input.css"><link rel="stylesheet" href="access.css"></head>
|
<link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"><link rel="stylesheet" href="mystery.css"><link rel="stylesheet" href="input.css"><link rel="stylesheet" href="access.css"><link rel="stylesheet" href="tutorial.css"></head>
|
||||||
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span><p class="boot-quote"></p></div></div><script src="content/loading.js"></script><script src="content/changelog.js"></script><script src="brand.js"></script><script src="content/story.js"></script><script src="story.js"></script><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
|
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span><p class="boot-quote"></p></div></div><script src="content/loading.js"></script><script src="content/changelog.js"></script><script src="brand.js"></script><script src="content/story.js"></script><script src="story.js"></script><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
|
||||||
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
|
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
|
||||||
<header class="combat-header">
|
<header class="combat-header">
|
||||||
|
|
@ -27,6 +27,6 @@
|
||||||
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
|
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
|
||||||
<!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js -->
|
<!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js -->
|
||||||
<link rel="stylesheet" href="stats.css"><script src="stats.js"></script>
|
<link rel="stylesheet" href="stats.css"><script src="stats.js"></script>
|
||||||
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
|
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="tutorial.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
1
menu.js
1
menu.js
|
|
@ -75,6 +75,7 @@
|
||||||
const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`;
|
const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`;
|
||||||
}
|
}
|
||||||
bind();attachBackground();window.Brand?.decorate(root,screen);window.Mystery?.decorate(root,screen,hover,lockedSlots);
|
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){
|
function updatePreview(play=true){
|
||||||
root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover));
|
root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover));
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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 = `<i aria-hidden="true">${ok ? '✓' : '▶'}</i><span>TUTORIAL</span>`;
|
||||||
|
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 = '<div class="tut-bar"><i></i></div><header class="tut-head"><span class="tut-step"></span><h2 class="tut-title"></h2><span class="tut-actions"><button type="button" class="tut-btn tut-skip">LEWATI ▸</button><button type="button" class="tut-btn tut-free" hidden>LATIHAN BEBAS</button><button type="button" class="tut-btn tut-exit">KELUAR ✕</button></span></header><p class="tut-text"></p><ul class="tut-goals"></ul><span class="tut-sr" role="status" aria-live="polite"></span>';
|
||||||
|
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 = '<b>!</b><small>L</small>';
|
||||||
|
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 `<li class="tut-goal${x.ok ? ' done' : ''}${fresh ? ' new' : ''}"><i class="box" aria-hidden="true">✓</i><span class="keys">${x.keys.map(k => (k === '+' ? '<b class="plus">+</b>' : `<kbd class="tut-key">${esc(k)}</kbd>`)).join('')}</span><span class="lab">${esc(x.label)}${x.wait ? ` <small>tunggu ${x.wait} dtk</small>` : ''}</span>${x.ok ? '<span class="tut-sr"> selesai</span>' : ''}</li>`; }).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)
|
||||||
|
};
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue