diff --git a/.htaccess b/.htaccess index a5ed4c5..9692e7c 100644 --- a/.htaccess +++ b/.htaccess @@ -8,6 +8,7 @@ AddType font/ttf .ttf AddType application/json .json AddType image/svg+xml .svg + AddType application/manifest+json .webmanifest @@ -16,5 +17,5 @@ # Text only: MP3/MP4 must stay uncompressed so range requests (206) keep working. - 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 diff --git a/DEPLOY.md b/DEPLOY.md index d36bd96..19a736c 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -40,7 +40,7 @@ If the main site runs WordPress or another CMS, that is fine: a real `konoha-fig Aim for: 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. 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|content-type" # 200 text/html 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 -s -o /dev/null -w "%{http_code}\n" -H "Range: bytes=0-99" \ "$BASE/assets/audio/music/midday-showdown.mp3" # 206 diff --git a/README.md b/README.md index 7caf220..c68cf33 100644 --- a/README.md +++ b/README.md @@ -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. +**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 - 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) ├── announcer.js # antrean suara pengumuman ├── 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//manifest.js) ├── preload.js # layar boot kunjungan pertama, mengisi cache ├── precache.js # daftar berkas runtime (dibuat oleh tools/build_precache.py) diff --git a/assets/pwa/apple-touch-icon.png b/assets/pwa/apple-touch-icon.png new file mode 100644 index 0000000..7c8a78d Binary files /dev/null and b/assets/pwa/apple-touch-icon.png differ diff --git a/assets/pwa/icon-192.png b/assets/pwa/icon-192.png new file mode 100644 index 0000000..46f1b4b Binary files /dev/null and b/assets/pwa/icon-192.png differ diff --git a/assets/pwa/icon-512.png b/assets/pwa/icon-512.png new file mode 100644 index 0000000..fe7c758 Binary files /dev/null and b/assets/pwa/icon-512.png differ diff --git a/assets/pwa/icon-maskable-192.png b/assets/pwa/icon-maskable-192.png new file mode 100644 index 0000000..60f7cd1 Binary files /dev/null and b/assets/pwa/icon-maskable-192.png differ diff --git a/assets/pwa/icon-maskable-512.png b/assets/pwa/icon-maskable-512.png new file mode 100644 index 0000000..99db711 Binary files /dev/null and b/assets/pwa/icon-maskable-512.png differ diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index e90cf16..1e9afce 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -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. - `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 ` + + + + + diff --git a/pwa.js b/pwa.js new file mode 100644 index 0000000..adbc291 --- /dev/null +++ b/pwa.js @@ -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 , 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 = 'Pasang aplikasiBuka dari layar utama, layar penuh tanpa bilah alamat'; + (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(); +})(); diff --git a/sw.js b/sw.js index 07be07d..cbd164c 100644 --- a/sw.js +++ b/sw.js @@ -2,10 +2,14 @@ worker only has to answer from it: - 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; - - 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'; -self.addEventListener('install', () => self.skipWaiting()); -self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); +const SHELL = 'konoha-offline-v1'; +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 => { const req = event.request; 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; 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))); + 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)) { event.respondWith(fromCache().then(hit => hit || fetch(req))); 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())))); }); diff --git a/tools/build_precache.py b/tools/build_precache.py index e28b2fe..69fcab0 100644 --- a/tools/build_precache.py +++ b/tools/build_precache.py @@ -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') 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)}) -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=[] for f in files: b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]]) diff --git a/tools/make_pwa_icons.py b/tools/make_pwa_icons.py new file mode 100644 index 0000000..a1aa771 --- /dev/null +++ b/tools/make_pwa_icons.py @@ -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') diff --git a/touchlayout.css b/touchlayout.css new file mode 100644 index 0000000..9cc7204 --- /dev/null +++ b/touchlayout.css @@ -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}} diff --git a/touchlayout.js b/touchlayout.js new file mode 100644 index 0000000..79d144a --- /dev/null +++ b/touchlayout.js @@ -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 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 = ''; + 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 = '
ATUR TOMBOL

Seret kotak untuk memindahkan.
Tarik lingkaran di sudutnya untuk mengubah ukuran.

'; + 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 = 'Tata letak tombol'; + (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)); } }; +})();