Gabung wf/t4-pwa: PWA dan tata letak sentuh
This commit is contained in:
commit
5dae5975fd
|
|
@ -8,6 +8,7 @@
|
||||||
AddType font/ttf .ttf
|
AddType font/ttf .ttf
|
||||||
AddType application/json .json
|
AddType application/json .json
|
||||||
AddType image/svg+xml .svg
|
AddType image/svg+xml .svg
|
||||||
|
AddType application/manifest+json .webmanifest
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
|
|
@ -16,5 +17,5 @@
|
||||||
|
|
||||||
# Text only: MP3/MP4 must stay uncompressed so range requests (206) keep working.
|
# Text only: MP3/MP4 must stay uncompressed so range requests (206) keep working.
|
||||||
<IfModule mod_deflate.c>
|
<IfModule mod_deflate.c>
|
||||||
AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json image/svg+xml
|
AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json image/svg+xml application/manifest+json
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ If the main site runs WordPress or another CMS, that is fine: a real `konoha-fig
|
||||||
Aim for:
|
Aim for:
|
||||||
|
|
||||||
1. `/konoha-fighting` → 301 → `/konoha-fighting/`
|
1. `/konoha-fighting` → 301 → `/konoha-fighting/`
|
||||||
2. Correct MIME types: `.js` JavaScript, `.webp` `image/webp`, `.mp3` `audio/mpeg`, `.mp4` `video/mp4`, `.ttf` `font/ttf`
|
2. Correct MIME types: `.js` JavaScript, `.webp` `image/webp`, `.mp3` `audio/mpeg`, `.mp4` `video/mp4`, `.ttf` `font/ttf`, `.webmanifest` `application/manifest+json` (PWA; recent nginx already maps it)
|
||||||
3. `Cache-Control: no-cache` for the game files. Browsers still reuse their copy after an ETag/Last-Modified check, and the game keeps hashed copies in Cache Storage, so updates show up at once. Avoid long `expires`/`max-age` rules for this path.
|
3. `Cache-Control: no-cache` for the game files. Browsers still reuse their copy after an ETag/Last-Modified check, and the game keeps hashed copies in Cache Storage, so updates show up at once. Avoid long `expires`/`max-age` rules for this path.
|
||||||
4. MP3/MP4 served with range requests (206), which is the default for static files, and not gzipped.
|
4. MP3/MP4 served with range requests (206), which is the default for static files, and not gzipped.
|
||||||
|
|
||||||
|
|
@ -95,6 +95,7 @@ BASE=https://example.com/konoha-fighting
|
||||||
curl -sI "$BASE" | grep -iE "^HTTP|^location" # 301 -> /konoha-fighting/
|
curl -sI "$BASE" | grep -iE "^HTTP|^location" # 301 -> /konoha-fighting/
|
||||||
curl -sI "$BASE/" | grep -iE "^HTTP|content-type" # 200 text/html
|
curl -sI "$BASE/" | grep -iE "^HTTP|content-type" # 200 text/html
|
||||||
curl -sI "$BASE/sw.js" | grep -iE "^HTTP|content-type|cache-control" # 200, javascript, no-cache
|
curl -sI "$BASE/sw.js" | grep -iE "^HTTP|content-type|cache-control" # 200, javascript, no-cache
|
||||||
|
curl -sI "$BASE/manifest.webmanifest" | grep -iE "^HTTP|content-type" # 200, application/manifest+json (PWA install)
|
||||||
curl -sI "$BASE/assets/stage.webp" | grep -iE "^HTTP|content-type" # 200 image/webp
|
curl -sI "$BASE/assets/stage.webp" | grep -iE "^HTTP|content-type" # 200 image/webp
|
||||||
curl -s -o /dev/null -w "%{http_code}\n" -H "Range: bytes=0-99" \
|
curl -s -o /dev/null -w "%{http_code}\n" -H "Range: bytes=0-99" \
|
||||||
"$BASE/assets/audio/music/midday-showdown.mp3" # 206
|
"$BASE/assets/audio/music/midday-showdown.mp3" # 206
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
|
||||||
|
|
||||||
**Sentuh (HP dan tablet):** pad arah di kiri, tombol serang besar dengan skill dan ultimate di kanan lengkap dengan hitungan cooldown, ditambah tombol jurus perintah, blok, dan lempar. HP otomatis membuka `mobile.html` dalam tampilan landscape; `?touch=1` memaksa tampilan sentuh di desktop dan `?touch=0` mematikannya.
|
**Sentuh (HP dan tablet):** pad arah di kiri, tombol serang besar dengan skill dan ultimate di kanan lengkap dengan hitungan cooldown, ditambah tombol jurus perintah, blok, dan lempar. HP otomatis membuka `mobile.html` dalam tampilan landscape; `?touch=1` memaksa tampilan sentuh di desktop dan `?touch=0` mematikannya.
|
||||||
|
|
||||||
|
**Atur tombol dan pasang aplikasi:** di layar sentuh, Pengaturan memuat baris *Tata letak tombol* (ATUR): seret kotak pad arah, tombol aksi, atau tombol tambahan untuk memindahkannya dan tarik lingkaran di sudutnya untuk mengubah ukuran; tombol ATUR ULANG mengembalikan ke bawaan. Tata letak tersimpan di perangkat. Bila peramban mengizinkan (Chrome, Edge, Android), Pengaturan juga menampilkan *Pasang aplikasi* agar game bisa dibuka layar penuh dari layar utama; di iPhone/iPad pasang lewat Bagikan, lalu Tambah ke Layar Utama.
|
||||||
|
|
||||||
## Fitur
|
## Fitur
|
||||||
|
|
||||||
- Mode: VS Komputer (4 tingkat, best of 3 ronde), Arcade, Survival, Dua Pemain, dan Latihan.
|
- Mode: VS Komputer (4 tingkat, best of 3 ronde), Arcade, Survival, Dua Pemain, dan Latihan.
|
||||||
|
|
@ -67,6 +69,8 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
|
||||||
├── content/ # data teks: loading.js (kutipan), changelog.js (Yang baru), mystery.js (petunjuk slot misteri)
|
├── content/ # data teks: loading.js (kutipan), changelog.js (Yang baru), mystery.js (petunjuk slot misteri)
|
||||||
├── announcer.js # antrean suara pengumuman
|
├── announcer.js # antrean suara pengumuman
|
||||||
├── touch.js # kontrol sentuh di layar
|
├── touch.js # kontrol sentuh di layar
|
||||||
|
├── touchlayout.js # editor tata letak tombol sentuh (+ touchlayout.css)
|
||||||
|
├── pwa.js # tombol "Pasang aplikasi" (beforeinstallprompt); manifest.webmanifest, offline.html, assets/pwa/
|
||||||
├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets/<id>/manifest.js)
|
├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets/<id>/manifest.js)
|
||||||
├── preload.js # layar boot kunjungan pertama, mengisi cache
|
├── preload.js # layar boot kunjungan pertama, mengisi cache
|
||||||
├── precache.js # daftar berkas runtime (dibuat oleh tools/build_precache.py)
|
├── precache.js # daftar berkas runtime (dibuat oleh tools/build_precache.py)
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 318 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
|
|
@ -20,6 +20,8 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
|
||||||
- Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa.
|
- Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa.
|
||||||
- `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`.
|
- `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`.
|
||||||
- Gamepad dan ubah tombol (Tahap 4.5): `input.js` + `input.css` (`window.Input`), dimuat tepat setelah `touch.js`; satu-satunya kait ke game.js adalah `__game.input` (plus pembungkus `__game.startMatch/setP2` untuk mengetahui mode dua pemain; bila kelak ada `__game.p2Active()`, itu yang dipakai). **Ubah tombol** (Pengaturan > "Ubah tombol…", dialog `#keymap-dialog`): 11 aksi Pemain 1 disimpan di `localStorage['konoha.keymap.v1']` sebagai `{aksi: {c: e.code, l: label}}`. Hanya bila pemetaan berbeda dari bawaan, keydown/keyup ditangkap di fase capture pada `window`, diterjemahkan ke kunci kanonik (a d w s spasi i o p k l j) lewat `__game.input`, dan event aslinya dihentikan; tombol bawaan yang sudah dipindah ditelan (termasuk keyup-nya, supaya game.js tidak melepas kunci kanonik yang ditahan lewat tombol baru). Tidak aktif di menu depan (`body.in-menu`), dialog terbuka, layar VS/kartu cerita, atau kolom teks; Spasi/Enter pada `<button>` dibiarkan seperti di game.js. Tidak bisa dipetakan: panah, N, M, koma, titik (Pemain 2), Esc, R, Enter, Tab, Ctrl/Alt/Cmd, tombol F; bentrok antaraksi = kedua tombol ditukar. Bantuan kontrol di Pengaturan ditulis ulang dari teks aslinya (5 baris pertama), label `kbd` di HUD (kartu skill, meter ultimate; meter ditulis ulang game.js tiap keadaannya berubah jadi diamati) dan Daftar gerakan menu jeda (hanya bagian Kontrol dasar, Serangan dan skill, Gerakan perintah, Mode) mengikuti pemetaan lewat MutationObserver tanpa mengubah game.js. Daftar gerakan di menu Karakter (meta.js) dan teks "tombol K/I/O" di toko belum mengikuti. **Gamepad**: polling `requestAnimationFrame` hanya selama ada gamepad (tata letak standar; pad pertama = P1, pad kedua = P2 bila mode dua pemain). Di arena: stik/D-pad = a d w s, A/Cross = w, X/Square = spasi, Y/Triangle, B/Circle, R1 = i o p, L1/L2/R2 = k l j, Start = Esc; pad P2 memakai kunci P2 (panah, n m , .). Di menu depan: keydown sintetis ArrowKeys/Enter/Escape yang ditangani menu.js (bila tidak ditangani, fokus dipindah dan tombol diklik); di dialog (pengaturan, jeda, ubah tombol), hamparan Meta, layar VS dan kartu cerita: fokus dipindah langsung, A = klik/Enter, B/Start = tutup (Esc lewat `__game.input('escape')`). Kontrol yang sedang ditekan saat konteks berganti diabaikan sampai dilepas. Getaran lewat `vibrationActuator` saat `hero.hp` turun (dimatikan lewat Pengaturan, `localStorage['konoha.pad.v1']`). Gamepad API hanya jalan di `localhost`/HTTPS. Belum ada skenario otomatis (diuji manual).
|
- Gamepad dan ubah tombol (Tahap 4.5): `input.js` + `input.css` (`window.Input`), dimuat tepat setelah `touch.js`; satu-satunya kait ke game.js adalah `__game.input` (plus pembungkus `__game.startMatch/setP2` untuk mengetahui mode dua pemain; bila kelak ada `__game.p2Active()`, itu yang dipakai). **Ubah tombol** (Pengaturan > "Ubah tombol…", dialog `#keymap-dialog`): 11 aksi Pemain 1 disimpan di `localStorage['konoha.keymap.v1']` sebagai `{aksi: {c: e.code, l: label}}`. Hanya bila pemetaan berbeda dari bawaan, keydown/keyup ditangkap di fase capture pada `window`, diterjemahkan ke kunci kanonik (a d w s spasi i o p k l j) lewat `__game.input`, dan event aslinya dihentikan; tombol bawaan yang sudah dipindah ditelan (termasuk keyup-nya, supaya game.js tidak melepas kunci kanonik yang ditahan lewat tombol baru). Tidak aktif di menu depan (`body.in-menu`), dialog terbuka, layar VS/kartu cerita, atau kolom teks; Spasi/Enter pada `<button>` dibiarkan seperti di game.js. Tidak bisa dipetakan: panah, N, M, koma, titik (Pemain 2), Esc, R, Enter, Tab, Ctrl/Alt/Cmd, tombol F; bentrok antaraksi = kedua tombol ditukar. Bantuan kontrol di Pengaturan ditulis ulang dari teks aslinya (5 baris pertama), label `kbd` di HUD (kartu skill, meter ultimate; meter ditulis ulang game.js tiap keadaannya berubah jadi diamati) dan Daftar gerakan menu jeda (hanya bagian Kontrol dasar, Serangan dan skill, Gerakan perintah, Mode) mengikuti pemetaan lewat MutationObserver tanpa mengubah game.js. Daftar gerakan di menu Karakter (meta.js) dan teks "tombol K/I/O" di toko belum mengikuti. **Gamepad**: polling `requestAnimationFrame` hanya selama ada gamepad (tata letak standar; pad pertama = P1, pad kedua = P2 bila mode dua pemain). Di arena: stik/D-pad = a d w s, A/Cross = w, X/Square = spasi, Y/Triangle, B/Circle, R1 = i o p, L1/L2/R2 = k l j, Start = Esc; pad P2 memakai kunci P2 (panah, n m , .). Di menu depan: keydown sintetis ArrowKeys/Enter/Escape yang ditangani menu.js (bila tidak ditangani, fokus dipindah dan tombol diklik); di dialog (pengaturan, jeda, ubah tombol), hamparan Meta, layar VS dan kartu cerita: fokus dipindah langsung, A = klik/Enter, B/Start = tutup (Esc lewat `__game.input('escape')`). Kontrol yang sedang ditekan saat konteks berganti diabaikan sampai dilepas. Getaran lewat `vibrationActuator` saat `hero.hp` turun (dimatikan lewat Pengaturan, `localStorage['konoha.pad.v1']`). Gamepad API hanya jalan di `localhost`/HTTPS. Belum ada skenario otomatis (diuji manual).
|
||||||
|
- 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`).
|
||||||
- `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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<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="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"></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"></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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"id": "konoha-fighting",
|
||||||
|
"name": "Konoha Fighting",
|
||||||
|
"short_name": "Konoha",
|
||||||
|
"description": "Game fighting parodi tokoh Indonesia. Pasang ke layar utama untuk main layar penuh.",
|
||||||
|
"lang": "id",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"display_override": ["fullscreen", "standalone"],
|
||||||
|
"orientation": "landscape",
|
||||||
|
"background_color": "#101923",
|
||||||
|
"theme_color": "#101923",
|
||||||
|
"categories": ["games", "entertainment"],
|
||||||
|
"icons": [
|
||||||
|
{ "src": "assets/pwa/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "assets/pwa/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "assets/pwa/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||||
|
{ "src": "assets/pwa/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,11 @@
|
||||||
<meta name="theme-color" content="#101923">
|
<meta name="theme-color" content="#101923">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Konoha Fighting">
|
||||||
|
<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">
|
||||||
|
<script src="pwa.js"></script>
|
||||||
<title>Konoha Fighting</title>
|
<title>Konoha Fighting</title>
|
||||||
<style>
|
<style>
|
||||||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#101923;overscroll-behavior:none;touch-action:none}
|
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#101923;overscroll-behavior:none;touch-action:none}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<!doctype html>
|
||||||
|
<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">
|
||||||
|
<title>Konoha Fighting · Offline</title>
|
||||||
|
<!-- Halaman cadangan: sw.js menampilkannya bila halaman game tidak bisa dimuat dari jaringan maupun dari cache. Berdiri sendiri (tanpa CSS/JS/gambar lain). -->
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;height:100%;background:#101923;color:#f9f0dc;font-family:Rajdhani,'Arial Narrow',Arial,sans-serif}
|
||||||
|
main{min-height:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:24px;text-align:center;background:radial-gradient(ellipse at 50% 38%,#173547,#0b1b25 70%)}
|
||||||
|
.logo{font-size:clamp(28px,8vh,52px);font-weight:700;letter-spacing:.14em;line-height:1;color:#e6c78f}
|
||||||
|
.logo em{display:block;font-style:normal;color:#f9f0dc;letter-spacing:.3em;font-size:.6em;margin-top:.2em}
|
||||||
|
h1{margin:6px 0 0;font-size:clamp(20px,5vh,28px)}
|
||||||
|
p{margin:0;max-width:34em;font-size:clamp(14px,3.6vh,17px);line-height:1.45;color:#b9cbd0}
|
||||||
|
button{margin-top:8px;border:0;border-radius:4px;background:#e6c78f;color:#132b37;font:700 17px/1 Rajdhani,Arial,sans-serif;letter-spacing:.06em;padding:12px 26px;cursor:pointer}
|
||||||
|
button:focus-visible{outline:2px solid #fff3d4;outline-offset:3px}
|
||||||
|
#net{min-height:1.4em;font-size:14px;color:#8fe3d0}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="logo">KONOHA<em>FIGHTING</em></div>
|
||||||
|
<h1>Kamu sedang offline</h1>
|
||||||
|
<p>Game belum bisa dimuat karena tidak ada koneksi internet. Pemuatan pertama memerlukan internet; setelah itu aset tersimpan di perangkat.</p>
|
||||||
|
<button id="retry" type="button">COBA LAGI</button>
|
||||||
|
<span id="net" role="status" aria-live="polite"></span>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
// Muat ulang otomatis begitu koneksi kembali; tombol untuk mencoba sendiri.
|
||||||
|
const net = document.getElementById('net');
|
||||||
|
const retry = () => { net.textContent = 'Memuat…'; location.reload(); };
|
||||||
|
document.getElementById('retry').onclick = () => { if (navigator.onLine === false) net.textContent = 'Masih offline. Periksa koneksi internetmu.'; else retry(); };
|
||||||
|
addEventListener('online', retry);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/* PWA (RENCANA 4.7): menangkap `beforeinstallprompt` supaya Pengaturan bisa menawarkan "Pasang aplikasi", hanya bila peramban mengizinkannya.
|
||||||
|
Dimuat di index.html dan mobile.html (sinkron di <head>, agar event tidak terlewat). Event dikirim ke dokumen paling atas: di HP dokumen itu mobile.html
|
||||||
|
dan game berjalan di iframe, jadi iframe memakai window.parent.KonohaPWA (satu asal; dipanggil langsung supaya ketukan pemain tetap dianggap gestur untuk prompt()).
|
||||||
|
window.KonohaPWA = { available(), install() -> 'accepted' | 'dismissed' | 'unavailable', onChange(fn) -> fungsi berhenti }.
|
||||||
|
iOS tidak punya event ini (pasang lewat Bagikan > Tambah ke Layar Utama), jadi di sana tombol tidak pernah tampil. */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
let host = null;
|
||||||
|
try { host = window.parent !== window ? window.parent.KonohaPWA || null : null; } catch (_) { host = null; }
|
||||||
|
if (!host) {
|
||||||
|
let saved = null, installed = false;
|
||||||
|
const subs = new Set();
|
||||||
|
const standalone = () => { try { return matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: fullscreen)').matches || navigator.standalone === true; } catch (_) { return false; } };
|
||||||
|
const emit = () => { for (const fn of [...subs]) { try { fn(); } catch (_) { subs.delete(fn); } } };
|
||||||
|
host = window.KonohaPWA = {
|
||||||
|
available: () => !!saved && !installed && !standalone(),
|
||||||
|
onChange(fn) { subs.add(fn); return () => subs.delete(fn); },
|
||||||
|
async install() {
|
||||||
|
const ev = saved;
|
||||||
|
if (!ev) return 'unavailable';
|
||||||
|
saved = null; emit(); // satu event hanya bisa dipakai sekali
|
||||||
|
try { ev.prompt(); return (await ev.userChoice).outcome; } catch (_) { return 'unavailable'; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); saved = e; emit(); });
|
||||||
|
window.addEventListener('appinstalled', () => { installed = true; saved = null; emit(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baris di Pengaturan (hanya di halaman game, bukan di mobile.html): tampil hanya selama pemasangan tersedia.
|
||||||
|
function mountRow() {
|
||||||
|
const panel = document.getElementById('settings-panel');
|
||||||
|
if (!panel || panel.querySelector('#pwa-row')) return;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'setting-row'; row.id = 'pwa-row'; row.style.display = 'none';
|
||||||
|
row.innerHTML = '<span>Pasang aplikasi<small>Buka dari layar utama, layar penuh tanpa bilah alamat</small></span><button type="button" class="kf-set-btn" id="pwa-install">PASANG</button>';
|
||||||
|
(panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(row);
|
||||||
|
const sync = () => { let ok = false; try { ok = host.available(); } catch (_) { /* host dari dokumen yang sudah dibongkar */ } row.style.display = ok ? '' : 'none'; };
|
||||||
|
row.querySelector('#pwa-install').onclick = () => { host.install(); };
|
||||||
|
try { host.onChange(sync); } catch (_) { /* tanpa langganan: status tetap dibaca lagi tiap Pengaturan dibuka */ }
|
||||||
|
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['open'] });
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountRow); else mountRow();
|
||||||
|
})();
|
||||||
13
sw.js
13
sw.js
|
|
@ -2,10 +2,14 @@
|
||||||
worker only has to answer from it:
|
worker only has to answer from it:
|
||||||
- pictures and fonts: cache first (instant screens, works offline);
|
- pictures and fonts: cache first (instant screens, works offline);
|
||||||
- pages, CSS and JS: network first, so a new deploy shows up at once; the cache is the offline fallback;
|
- pages, CSS and JS: network first, so a new deploy shows up at once; the cache is the offline fallback;
|
||||||
- audio/video and range requests: left to the network and HTTP cache (Safari needs real 206 range answers). */
|
- audio/video and range requests: left to the network and HTTP cache (Safari needs real 206 range answers);
|
||||||
|
- a page that can be loaded from neither the network nor the cache gets offline.html (PWA, RENCANA 4.7). It lives in its own small cache
|
||||||
|
because preload.js drops every 'aether-assets' entry that is not listed in precache.js. */
|
||||||
const CACHE = 'aether-assets';
|
const CACHE = 'aether-assets';
|
||||||
self.addEventListener('install', () => self.skipWaiting());
|
const SHELL = 'konoha-offline-v1';
|
||||||
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
|
const OFFLINE = 'offline.html';
|
||||||
|
self.addEventListener('install', event => event.waitUntil(caches.open(SHELL).then(cache => cache.add(new Request(OFFLINE, { cache: 'reload' }))).catch(() => {}).then(() => self.skipWaiting())));
|
||||||
|
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith('konoha-offline-') && key !== SHELL).map(key => caches.delete(key)))).catch(() => {}).then(() => self.clients.claim())));
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
const req = event.request;
|
const req = event.request;
|
||||||
if (req.method !== 'GET') return;
|
if (req.method !== 'GET') return;
|
||||||
|
|
@ -13,9 +17,10 @@ self.addEventListener('fetch', event => {
|
||||||
if (url.origin !== location.origin || req.headers.has('range') || /\.(mp3|mp4)$/i.test(url.pathname)) return;
|
if (url.origin !== location.origin || req.headers.has('range') || /\.(mp3|mp4)$/i.test(url.pathname)) return;
|
||||||
const fromCache = () => caches.open(CACHE).then(cache => cache.match(url.href, { ignoreSearch: true })
|
const fromCache = () => caches.open(CACHE).then(cache => cache.match(url.href, { ignoreSearch: true })
|
||||||
.then(hit => hit || (url.pathname.endsWith('/') ? cache.match(new URL('index.html', url).href) : undefined)));
|
.then(hit => hit || (url.pathname.endsWith('/') ? cache.match(new URL('index.html', url).href) : undefined)));
|
||||||
|
const offlinePage = () => caches.match(new URL(OFFLINE, self.registration.scope).href).then(hit => hit || Response.error());
|
||||||
if (/\.(webp|png|svg|ttf|woff2)$/i.test(url.pathname)) {
|
if (/\.(webp|png|svg|ttf|woff2)$/i.test(url.pathname)) {
|
||||||
event.respondWith(fromCache().then(hit => hit || fetch(req)));
|
event.respondWith(fromCache().then(hit => hit || fetch(req)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.respondWith(fetch(req).catch(() => fromCache().then(hit => hit || Response.error())));
|
event.respondWith(fetch(req).catch(() => fromCache().then(hit => hit || (req.mode === 'navigate' ? offlinePage() : Response.error()))));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ content=sorted('content/'+f for f in os.listdir('content') if f.endswith(('.js',
|
||||||
tracked=subprocess.run(['git','ls-files','assets'],capture_output=True,text=True).stdout.split('\n')
|
tracked=subprocess.run(['git','ls-files','assets'],capture_output=True,text=True).stdout.split('\n')
|
||||||
others=subprocess.run(['git','ls-files','--others','--exclude-standard','assets'],capture_output=True,text=True).stdout.split('\n')
|
others=subprocess.run(['git','ls-files','--others','--exclude-standard','assets'],capture_output=True,text=True).stdout.split('\n')
|
||||||
assets=sorted({f for f in tracked+others if f and os.path.isfile(f)})
|
assets=sorted({f for f in tracked+others if f and os.path.isfile(f)})
|
||||||
files=[f for f in root+content+assets if os.path.isfile(f)]
|
# PWA: manifest (ekstensi .webmanifest tidak ikut daftar di atas) dan ikon assets/pwa; ikon dipastikan ikut meski git tidak tersedia. offline.html, pwa.js, touchlayout.* ikut lewat daftar root.
|
||||||
|
extra=['manifest.webmanifest']
|
||||||
|
pwa=sorted('assets/pwa/'+f for f in os.listdir('assets/pwa') if not f.startswith('.')) if os.path.isdir('assets/pwa') else []
|
||||||
|
files=[f for f in dict.fromkeys(root+extra+content+assets+pwa) if os.path.isfile(f)]
|
||||||
entries=[]
|
entries=[]
|
||||||
for f in files:
|
for f in files:
|
||||||
b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]])
|
b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# Membuat ikon PWA (assets/pwa/*.png) dari potret yang sudah ada di repo, tanpa generator gambar:
|
||||||
|
# crop kotak dari assets/menu/browo-select.webp, ubah ukuran, tempel di atas latar gradasi tema game.
|
||||||
|
# Jalankan dari mana saja: python3 tools/make_pwa_icons.py (hanya butuh Pillow)
|
||||||
|
import os
|
||||||
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||||
|
|
||||||
|
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
|
||||||
|
SRC = os.path.join(ROOT, 'assets', 'menu', 'browo-select.webp')
|
||||||
|
OUT = os.path.join(ROOT, 'assets', 'pwa')
|
||||||
|
TOP, BOTTOM, GOLD = (31, 91, 102), (11, 27, 37), (230, 199, 143) # warna tema: teal gelap, biru malam, emas
|
||||||
|
CROP = (50, 0, 690, 640) # topi, wajah, dua tinju, dada (sprite 740x964)
|
||||||
|
HEAD = 330 # tinggi bagian kepala pada hasil crop (topi + wajah), dipakai untuk zona aman maskable
|
||||||
|
|
||||||
|
|
||||||
|
def background(n):
|
||||||
|
bg = Image.composite(Image.new('RGB', (n, n), BOTTOM), Image.new('RGB', (n, n), TOP), Image.linear_gradient('L').resize((n, n)))
|
||||||
|
size = int(n * 1.15) # cahaya emas lembut di belakang kepala
|
||||||
|
glow = Image.radial_gradient('L').resize((size, size)).point(lambda v: max(0, 181 - v) * 95 // 181) # nol tepat di tepi lingkaran: tanpa pita
|
||||||
|
mask = Image.new('L', (n, n), 0)
|
||||||
|
mask.paste(glow, ((n - size) // 2, int(n * .36) - size // 2))
|
||||||
|
bg.paste(Image.new('RGB', (n, n), GOLD), mask=mask)
|
||||||
|
return bg
|
||||||
|
|
||||||
|
|
||||||
|
def sprite():
|
||||||
|
return Image.open(SRC).convert('RGBA').crop(CROP)
|
||||||
|
|
||||||
|
|
||||||
|
def inside_circle(alpha, size, center, radius):
|
||||||
|
# True bila semua piksel sprite (alfa > 40) berada dalam lingkaran: dipakai untuk zona aman maskable
|
||||||
|
circle = Image.new('L', size, 0)
|
||||||
|
ImageDraw.Draw(circle).ellipse((center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius), fill=255)
|
||||||
|
return ImageChops.subtract(alpha.point(lambda v: 255 if v > 40 else 0), circle).getbbox() is None
|
||||||
|
|
||||||
|
|
||||||
|
def place(canvas, spr, scale, cx, bottom=None, cy=None):
|
||||||
|
w, h = round(spr.width * scale), round(spr.height * scale)
|
||||||
|
s = spr.resize((w, h), Image.LANCZOS)
|
||||||
|
x = round(cx - w / 2)
|
||||||
|
y = round(bottom - h) if bottom is not None else round(cy - h / 2)
|
||||||
|
layer = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
|
||||||
|
layer.paste(s, (x, y))
|
||||||
|
shadow = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
|
||||||
|
shadow.putalpha(layer.getchannel('A').filter(ImageFilter.GaussianBlur(canvas.width / 64)).point(lambda v: v * 6 // 10))
|
||||||
|
canvas.alpha_composite(shadow, (0, round(canvas.width / 100)))
|
||||||
|
canvas.alpha_composite(layer)
|
||||||
|
|
||||||
|
|
||||||
|
def icon_any(n):
|
||||||
|
# Kotak membulat dengan garis emas tipis; sudut luar transparan (peramban desktop tidak memotongnya)
|
||||||
|
canvas = background(n).convert('RGBA')
|
||||||
|
spr = sprite()
|
||||||
|
place(canvas, spr, n * .9 / spr.width, n / 2, bottom=n * .985)
|
||||||
|
big = n * 4 # bingkai dan topeng digambar 4x lalu diperkecil supaya tepinya halus
|
||||||
|
frame = Image.new('RGBA', (big, big), (0, 0, 0, 0))
|
||||||
|
ImageDraw.Draw(frame).rounded_rectangle((big * .012, big * .012, big * .988, big * .988), radius=big * .2, outline=GOLD + (235,), width=max(8, big // 60))
|
||||||
|
canvas.alpha_composite(frame.resize((n, n), Image.LANCZOS))
|
||||||
|
mask = Image.new('L', (big, big), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle((0, 0, big - 1, big - 1), radius=big * .205, fill=255)
|
||||||
|
canvas.putalpha(ImageChops.multiply(canvas.getchannel('A'), mask.resize((n, n), Image.LANCZOS)))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def icon_bleed(n):
|
||||||
|
# Penuh tanpa sudut transparan (apple-touch-icon; iOS membulatkan sendiri)
|
||||||
|
canvas = background(n).convert('RGBA')
|
||||||
|
spr = sprite()
|
||||||
|
place(canvas, spr, n * 1.02 / spr.width, n / 2, bottom=n * 1.02)
|
||||||
|
return canvas.convert('RGB')
|
||||||
|
|
||||||
|
|
||||||
|
def icon_maskable(n):
|
||||||
|
# Topi dan wajah (bagian atas sprite) harus muat dalam lingkaran aman (radius 40% sisi) agar tidak terpotong
|
||||||
|
# topeng ikon adaptif; bahu dan tinju boleh terpotong. Sprite menempel ke dasar supaya tidak terlihat melayang.
|
||||||
|
canvas = background(n).convert('RGBA')
|
||||||
|
spr = sprite()
|
||||||
|
c = (n / 2, n / 2)
|
||||||
|
lo, hi = 0, n / spr.width * 1.2 # skala: piksel ikon per piksel sprite; cari yang terbesar yang masih muat
|
||||||
|
for _ in range(14):
|
||||||
|
k = (lo + hi) / 2
|
||||||
|
w, h = round(spr.width * k), round(spr.height * k)
|
||||||
|
probe = Image.new('L', (n, n), 0)
|
||||||
|
probe.paste(spr.resize((w, h), Image.LANCZOS).getchannel('A').crop((0, 0, w, round(HEAD * k))), (round(c[0] - w / 2), n - h))
|
||||||
|
if inside_circle(probe, (n, n), c, n * .395): lo = k
|
||||||
|
else: hi = k
|
||||||
|
place(canvas, spr, lo, c[0], bottom=n)
|
||||||
|
return canvas.convert('RGB')
|
||||||
|
|
||||||
|
|
||||||
|
def save(img, name):
|
||||||
|
img.save(os.path.join(OUT, name), optimize=True)
|
||||||
|
print(name, img.size, os.path.getsize(os.path.join(OUT, name)) // 1024, 'KB')
|
||||||
|
|
||||||
|
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
for n in (192, 512):
|
||||||
|
save(icon_any(n), f'icon-{n}.png')
|
||||||
|
save(icon_maskable(n), f'icon-maskable-{n}.png')
|
||||||
|
save(icon_bleed(180), 'apple-touch-icon.png')
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/* Tata letak tombol sentuh yang bisa diatur (RENCANA 4.6, touchlayout.js) dan tombol kecil di baris Pengaturan (RENCANA 4.7, pwa.js). */
|
||||||
|
.kf-set-btn{flex:none;margin-left:12px;min-height:36px;padding:6px 16px;border:0;border-radius:4px;background:#e8b84a;color:#101923;font:700 15px Rajdhani,system-ui,sans-serif;letter-spacing:.06em;cursor:pointer}
|
||||||
|
.kf-set-btn:hover{background:#f3cb6b}
|
||||||
|
|
||||||
|
/* Tata letak kustom pada kontrol asli: hanya elemen berkelas .tl-xf yang berubah; titik tumpu = sudut dekat tepi layar, jadi pembesaran menjauhi tepi. */
|
||||||
|
.tl-xf{transform:translate(var(--tl-x,0px),var(--tl-y,0px)) scale(var(--tl-s,1))}
|
||||||
|
.dpad.tl-xf{transform-origin:0 100%}
|
||||||
|
.action-pad.tl-xf,.extra-pad.tl-xf{transform-origin:100% 100%}
|
||||||
|
|
||||||
|
/* Editor: dialog modal layar penuh di atas Pengaturan (Pengaturan disembunyikan, tetap terbuka di bawahnya supaya game tetap beku). */
|
||||||
|
html.tl-editing #settings-panel{visibility:hidden}
|
||||||
|
html.tl-editing #settings-panel::backdrop{background:transparent;backdrop-filter:none}
|
||||||
|
#tl-editor{position:fixed;inset:0;margin:0;padding:0;width:100%;height:100%;max-width:none;max-height:none;border:0;background:transparent;box-shadow:none;overflow:hidden;color:#f9f0dc;font-family:Rajdhani,'Arial Narrow',sans-serif;touch-action:none;-webkit-user-select:none;user-select:none}
|
||||||
|
#tl-editor::backdrop{background:#031423b3;backdrop-filter:none}
|
||||||
|
#tl-editor.tl-over-menu::backdrop{background:#031423ee}
|
||||||
|
.tl-bar{position:absolute;left:0;right:0;top:0;z-index:9999;height:44px;display:flex;align-items:center;gap:8px;padding:0 max(10px,env(safe-area-inset-right)) 0 max(12px,env(safe-area-inset-left));background:#0b1b25f2;border-bottom:2px solid var(--gold,#e6c78f)}
|
||||||
|
.tl-bar strong{margin-right:auto;font-size:17px;letter-spacing:.16em;color:var(--gold,#e6c78f);white-space:nowrap}
|
||||||
|
.tl-bar button{min-height:34px;padding:0 12px;border:1px solid #8da8b153;background:#274352;color:#f9f0dc;font:700 14px Rajdhani,system-ui,sans-serif;letter-spacing:.06em;white-space:nowrap}
|
||||||
|
.tl-bar button.tl-done{border-color:transparent;background:var(--gold,#e6c78f);color:#132b37}
|
||||||
|
.tl-stage{position:absolute;inset:0;touch-action:none}
|
||||||
|
.tl-hint{position:absolute;left:50%;top:50%;z-index:1;margin:0;max-width:min(92%,430px);transform:translate(-50%,-50%);text-align:center;font-size:clamp(13px,3.4vh,17px);line-height:1.5;color:#dfeaec;text-shadow:0 1px 4px #000c;pointer-events:none}
|
||||||
|
.tl-clone,.tl-clone *{pointer-events:none!important}
|
||||||
|
.tl-box{position:absolute;z-index:2;box-sizing:border-box;border:2px dashed #e6c78fcc;border-radius:12px;background:#e6c78f1c;cursor:grab;touch-action:none}
|
||||||
|
.tl-box.on{border-style:solid;border-color:#fff3d4;background:#e6c78f33;cursor:grabbing}
|
||||||
|
.tl-box:focus-visible{outline:2px solid #fff3d4;outline-offset:2px}
|
||||||
|
.tl-name{position:absolute;left:50%;top:3px;transform:translateX(-50%);padding:0 7px;border-radius:9px;background:var(--gold,#e6c78f);color:#101923;font-size:11px;font-weight:700;letter-spacing:.12em;line-height:16px;white-space:nowrap;pointer-events:none}
|
||||||
|
.tl-grip{position:absolute;top:-2px;width:30px;height:30px;border:2px solid #101923;border-radius:50%;box-shadow:0 2px 6px #0009;touch-action:none;cursor:nesw-resize;background:var(--gold,#e6c78f) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 13 13 3M8 3h5v5M3 8v5h5' fill='none' stroke='%23101923' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/16px no-repeat}
|
||||||
|
.tl-grip:before{content:'';position:absolute;inset:-9px}
|
||||||
|
.tl-grip.tr{right:-2px}
|
||||||
|
.tl-grip.tl{left:-2px;cursor:nwse-resize;transform:scaleX(-1)}
|
||||||
|
@media(max-height:420px){.tl-hint{font-size:13px}}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/* Tata letak tombol sentuh yang bisa diatur (RENCANA 4.6). Hanya aktif di html.touch.
|
||||||
|
Pengaturan > "Tata letak tombol" > ATUR membuka editor layar penuh: sebuah <dialog> modal di atas Pengaturan (jadi game tetap beku, sama seperti saat
|
||||||
|
Pengaturan terbuka). Salinan pad arah (.dpad), tombol aksi (.action-pad), dan panel tambahan (.extra-pad) dibungkus kotak yang bisa diseret (pindah)
|
||||||
|
atau ditarik pegangan sudutnya (ukuran). Salinan itu tidak punya pendengar apa pun dan dokumen lain tidak aktif selama editor terbuka, jadi tidak ada
|
||||||
|
input yang terkirim ke game. touch.js tidak diubah: hasilnya hanya variabel CSS --tl-x/--tl-y/--tl-s dan kelas .tl-xf pada elemen asli (transformasi
|
||||||
|
ada di touchlayout.css; titik tumpunya sudut yang dekat tepi layar, jadi pembesaran selalu menjauhi tepi).
|
||||||
|
Simpanan: localStorage['konoha.touchlayout.v1'] = {v:1, g:{dpad|action|extra:{x,y,s}}}; x,y = geseran sebagai pecahan lebar/tinggi layar, s = skala.
|
||||||
|
Posisi dijepit ke dalam layar memakai kotak nyata hasil ukur (juga saat ukuran layar berubah); jepitan itu tidak menimpa nilai simpanan. */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (!html.classList.contains('touch')) return;
|
||||||
|
const root = document.getElementById('touch-controls'), panel = document.getElementById('settings-panel');
|
||||||
|
if (!root || !panel) return;
|
||||||
|
const KEY = 'konoha.touchlayout.v1', S_MIN = .6, S_MAX = 1.8, GAP = 4, BAR = 46; // BAR = tinggi bilah editor; area tombol dimulai di bawahnya
|
||||||
|
const GROUPS = [
|
||||||
|
{ id: 'dpad', sel: '.dpad', name: 'ARAH', label: 'pad arah', grip: 'tr' },
|
||||||
|
{ id: 'action', sel: '.action-pad', name: 'AKSI', label: 'tombol aksi', grip: 'tl' },
|
||||||
|
{ id: 'extra', sel: '.extra-pad', name: 'TAMBAHAN', label: 'tombol tambahan', grip: 'tl' },
|
||||||
|
].map(g => ({ ...g, node: root.querySelector(g.sel) })).filter(g => g.node);
|
||||||
|
if (!GROUPS.length) return;
|
||||||
|
|
||||||
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)), r4 = v => Math.round(v * 1e4) / 1e4, num = v => typeof v === 'number' && isFinite(v);
|
||||||
|
const blank = () => ({ x: 0, y: 0, s: 1 });
|
||||||
|
const isBlank = st => GROUPS.every(g => !st[g.id].x && !st[g.id].y && st[g.id].s === 1);
|
||||||
|
function read() {
|
||||||
|
let raw = null;
|
||||||
|
try { raw = JSON.parse(localStorage.getItem(KEY)); } catch (_) { /* simpanan rusak atau diblokir: pakai tata letak bawaan */ }
|
||||||
|
const st = {};
|
||||||
|
for (const g of GROUPS) {
|
||||||
|
const v = raw && raw.v === 1 && raw.g && raw.g[g.id], o = blank();
|
||||||
|
if (v && num(v.x) && num(v.y) && num(v.s)) { o.x = clamp(v.x, -1, 1); o.y = clamp(v.y, -1, 1); o.s = clamp(v.s, S_MIN, S_MAX); }
|
||||||
|
st[g.id] = o;
|
||||||
|
}
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
function write(st) { try { if (isBlank(st)) localStorage.removeItem(KEY); else localStorage.setItem(KEY, JSON.stringify({ v: 1, g: st })); } catch (_) { /* tidak tersimpan; tata letak berlaku sampai halaman ditutup */ } }
|
||||||
|
let state = read();
|
||||||
|
let ed = null; // editor yang sedang terbuka: { dlg, snap, parts: {id: {g, clone, frame, shown}}, z }
|
||||||
|
const cur = id => ({ dx: state[id].x * innerWidth, dy: state[id].y * innerHeight, s: state[id].s });
|
||||||
|
|
||||||
|
// Batas area tombol: layar dikurangi tepi aman (takik), jarak kecil ke tepi, dan bilah editor di atas.
|
||||||
|
let area = null;
|
||||||
|
addEventListener('resize', () => { area = null; });
|
||||||
|
function bounds() {
|
||||||
|
if (area) return area;
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)';
|
||||||
|
document.body.append(p);
|
||||||
|
const c = getComputedStyle(p), n = k => parseFloat(c[k]) || 0;
|
||||||
|
area = { l: n('paddingLeft') + GAP, t: n('paddingTop') + BAR, r: innerWidth - n('paddingRight') - GAP, b: innerHeight - n('paddingBottom') - GAP };
|
||||||
|
p.remove();
|
||||||
|
return area;
|
||||||
|
}
|
||||||
|
// Menerapkan {dx, dy, s} pada node, mengukur kotak nyatanya, lalu menjepit ukuran dan posisi ke dalam batas. Mengembalikan nilai akhir.
|
||||||
|
// Node tersembunyi (mis. kontrol asli saat di menu) tidak bisa diukur: nilai dipasang apa adanya dan dijepit nanti saat tampil (lihat ResizeObserver).
|
||||||
|
function fit(node, v, b) {
|
||||||
|
const o = { dx: v.dx, dy: v.dy, s: clamp(v.s, S_MIN, S_MAX) };
|
||||||
|
const put = () => { node.style.setProperty('--tl-x', o.dx + 'px'); node.style.setProperty('--tl-y', o.dy + 'px'); node.style.setProperty('--tl-s', String(o.s)); };
|
||||||
|
put();
|
||||||
|
let r = node.getBoundingClientRect();
|
||||||
|
if (!r.width || !r.height) return o;
|
||||||
|
const f = Math.min(1, (b.r - b.l) / r.width, (b.b - b.t) / r.height);
|
||||||
|
if (f < 1) { o.s = Math.max(S_MIN, o.s * f); put(); r = node.getBoundingClientRect(); }
|
||||||
|
o.dx += r.left < b.l ? b.l - r.left : r.right > b.r ? b.r - r.right : 0;
|
||||||
|
o.dy += r.top < b.t ? b.t - r.top : r.bottom > b.b ? b.b - r.bottom : 0;
|
||||||
|
put();
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elemen asli: pasang tata letak simpanan; kelas dan variabel dilepas seluruhnya bila nilainya bawaan (tampilan sama persis seperti sebelum fitur ini).
|
||||||
|
function applyReal() {
|
||||||
|
const b = bounds();
|
||||||
|
for (const g of GROUPS) {
|
||||||
|
const st = state[g.id];
|
||||||
|
if (!st.x && !st.y && st.s === 1) { g.node.classList.remove('tl-xf'); for (const k of ['--tl-x', '--tl-y', '--tl-s']) g.node.style.removeProperty(k); continue; }
|
||||||
|
g.node.classList.add('tl-xf');
|
||||||
|
fit(g.node, cur(g.id), b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyReal();
|
||||||
|
// Kontrol asli tersembunyi di menu dan baru bisa diukur saat tampil di arena: ResizeObserver menjepit ulang pada saat itu (sebelum digambar) dan saat layar berubah.
|
||||||
|
if (typeof ResizeObserver === 'function') new ResizeObserver(() => applyReal()).observe(root); else addEventListener('resize', applyReal);
|
||||||
|
// Sabuk pengaman: selama editor terbuka, sentuhan tidak boleh sampai ke tombol asli (dokumen sudah tidak aktif karena dialog modal).
|
||||||
|
root.addEventListener('pointerdown', e => { if (ed) { e.stopPropagation(); e.preventDefault(); } }, true);
|
||||||
|
|
||||||
|
// ---- editor ----
|
||||||
|
const store = (p, o) => { state[p.g.id] = { x: r4(o.dx / innerWidth), y: r4(o.dy / innerHeight), s: r4(o.s) }; };
|
||||||
|
function paint(p, v) {
|
||||||
|
const o = fit(p.clone, v, bounds());
|
||||||
|
p.shown = o;
|
||||||
|
const r = p.clone.getBoundingClientRect();
|
||||||
|
Object.assign(p.frame.style, { left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' });
|
||||||
|
p.frame.querySelector('.tl-name').textContent = p.g.name + (Math.abs(o.s - 1) > .005 ? ' ' + Math.round(o.s * 100) + '%' : '');
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
function repaint() { area = null; if (ed) for (const id in ed.parts) paint(ed.parts[id], cur(id)); }
|
||||||
|
function reset() { for (const g of GROUPS) { state[g.id] = blank(); paint(ed.parts[g.id], cur(g.id)); } }
|
||||||
|
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); finish(false); } } // Esc milik editor, bukan milik game (yang akan menutup Pengaturan)
|
||||||
|
|
||||||
|
// Satu bagian editor: salinan visual tombol (tanpa pendengar, tanpa input) + kotak seret di atasnya.
|
||||||
|
function part(g, stage) {
|
||||||
|
const clone = g.node.cloneNode(true);
|
||||||
|
clone.classList.add('tl-xf', 'tl-clone'); clone.inert = true;
|
||||||
|
for (const n of clone.querySelectorAll('.cooldown-wipe,.cooldown-num')) n.remove();
|
||||||
|
for (const n of [clone, ...clone.querySelectorAll('*')]) { n.classList.remove('pressed', 'active', 'recharged', 'on-cooldown'); for (const a of ['data-key', 'data-slot', 'tabindex', 'aria-label', 'aria-hidden']) n.removeAttribute(a); }
|
||||||
|
const frame = document.createElement('div');
|
||||||
|
frame.className = 'tl-box'; frame.tabIndex = 0; frame.setAttribute('role', 'group');
|
||||||
|
frame.setAttribute('aria-label', g.label + ': panah menggeser, plus dan minus mengubah ukuran');
|
||||||
|
frame.innerHTML = '<span class="tl-name"></span><i class="tl-grip ' + g.grip + '"></i>';
|
||||||
|
stage.append(clone, frame);
|
||||||
|
const p = { g, clone, frame, shown: cur(g.id) };
|
||||||
|
let d = null; // seretan yang sedang berjalan pada kotak ini (tiap kotak punya jarinya sendiri)
|
||||||
|
frame.addEventListener('pointerdown', e => {
|
||||||
|
if (d || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r = clone.getBoundingClientRect(), ax = g.grip === 'tl' ? r.right : r.left, ay = r.bottom; // titik tumpu = sudut yang tidak ikut bergerak saat ukuran berubah
|
||||||
|
d = { id: e.pointerId, grip: !!e.target.closest('.tl-grip'), x: e.clientX, y: e.clientY, v: { ...p.shown }, ax, ay, len: Math.max(24, Math.hypot(e.clientX - ax, e.clientY - ay)) };
|
||||||
|
try { frame.setPointerCapture(e.pointerId); } catch (_) { /* tanpa capture seretan tetap jalan selama jari di atas kotak */ }
|
||||||
|
frame.classList.add('on'); frame.style.zIndex = String(++ed.z);
|
||||||
|
});
|
||||||
|
frame.addEventListener('pointermove', e => {
|
||||||
|
if (!d || e.pointerId !== d.id) return;
|
||||||
|
const v = d.grip ? { dx: d.v.dx, dy: d.v.dy, s: d.v.s * Math.hypot(e.clientX - d.ax, e.clientY - d.ay) / d.len } : { dx: d.v.dx + e.clientX - d.x, dy: d.v.dy + e.clientY - d.y, s: d.v.s };
|
||||||
|
store(p, paint(p, v));
|
||||||
|
});
|
||||||
|
const end = e => { if (d && e.pointerId === d.id) { d = null; frame.classList.remove('on'); } };
|
||||||
|
for (const t of ['pointerup', 'pointercancel', 'lostpointercapture']) frame.addEventListener(t, end);
|
||||||
|
frame.addEventListener('keydown', e => { // papan ketik: panah menggeser (Shift = lebih jauh), + dan - mengubah ukuran
|
||||||
|
const step = e.shiftKey ? 24 : 8, move = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
|
||||||
|
if (!move && !['+', '=', '-', '_'].includes(e.key)) return;
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const v = { ...p.shown };
|
||||||
|
if (move) { v.dx += move[0]; v.dy += move[1]; } else v.s *= ['+', '='].includes(e.key) ? 1.06 : 1 / 1.06;
|
||||||
|
store(p, paint(p, v));
|
||||||
|
});
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (ed) return;
|
||||||
|
const dlg = document.createElement('dialog');
|
||||||
|
dlg.id = 'tl-editor'; dlg.className = document.body.classList.contains('in-menu') ? 'tl-over-menu' : '';
|
||||||
|
dlg.setAttribute('aria-label', 'Atur tata letak tombol sentuh');
|
||||||
|
dlg.innerHTML = '<div class="tl-bar"><strong>ATUR TOMBOL</strong><button type="button" data-tl="reset">ATUR ULANG</button><button type="button" data-tl="cancel">BATAL</button><button type="button" data-tl="done" class="tl-done">SELESAI</button></div><div class="tl-stage"><p class="tl-hint">Seret kotak untuk memindahkan.<br>Tarik lingkaran di sudutnya untuk mengubah ukuran.</p></div>';
|
||||||
|
ed = { dlg, snap: JSON.parse(JSON.stringify(state)), parts: {}, z: 2 };
|
||||||
|
try {
|
||||||
|
const stage = dlg.querySelector('.tl-stage');
|
||||||
|
for (const g of GROUPS) ed.parts[g.id] = part(g, stage);
|
||||||
|
dlg.addEventListener('click', e => { const act = e.target.closest('[data-tl]')?.dataset.tl; if (act === 'reset') reset(); else if (act === 'cancel') finish(false); else if (act === 'done') finish(true); });
|
||||||
|
dlg.addEventListener('close', () => finish(false)); // ditutup peramban (mis. tombol Kembali di Android): perubahan dibuang
|
||||||
|
document.body.append(dlg);
|
||||||
|
html.classList.add('tl-editing'); // menyembunyikan Pengaturan yang masih terbuka di bawah editor
|
||||||
|
addEventListener('keydown', onKey, true); addEventListener('resize', repaint);
|
||||||
|
dlg.showModal();
|
||||||
|
repaint();
|
||||||
|
dlg.querySelector('.tl-done').focus();
|
||||||
|
} catch (err) { console.error(err); finish(false); }
|
||||||
|
}
|
||||||
|
function finish(save) {
|
||||||
|
if (!ed) return;
|
||||||
|
const { dlg, snap } = ed; ed = null;
|
||||||
|
removeEventListener('keydown', onKey, true); removeEventListener('resize', repaint);
|
||||||
|
if (save) write(state); else state = snap;
|
||||||
|
html.classList.remove('tl-editing'); // sebelum dlg.close(): fokus harus kembali ke tombol ATUR yang harus sudah terlihat
|
||||||
|
if (dlg.open) dlg.close();
|
||||||
|
dlg.remove();
|
||||||
|
applyReal(); status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baris di Pengaturan, di atas tombol Tes audio / Layar penuh (setelah baris kode cheat dari meta.js).
|
||||||
|
function status() { const m = panel.querySelector('#tl-msg'); if (m) m.textContent = isBlank(state) ? 'Geser dan ubah ukuran pad arah dan tombol aksi' : 'Tata letak kustom aktif'; }
|
||||||
|
if (!panel.querySelector('#tl-row')) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'setting-row'; row.id = 'tl-row';
|
||||||
|
row.innerHTML = '<span>Tata letak tombol<small id="tl-msg"></small></span><button type="button" class="kf-set-btn" id="tl-open">ATUR</button>';
|
||||||
|
(panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(row);
|
||||||
|
row.querySelector('#tl-open').onclick = open;
|
||||||
|
status();
|
||||||
|
}
|
||||||
|
window.TouchLayout = { open, get editing() { return !!ed; }, get layout() { return JSON.parse(JSON.stringify(state)); } };
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue