Gabung wf/t4-input

This commit is contained in:
rizkyaz0 2026-10-05 13:56:11 +07:00
commit 963cfbb30b
4 changed files with 485 additions and 2 deletions

View File

@ -19,6 +19,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`.
- 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 `<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).
- `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).
- `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.

View File

@ -1,7 +1,7 @@
<!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"><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="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"><link rel="stylesheet" href="mystery.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">
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
<header class="combat-header">
@ -24,6 +24,6 @@
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div>
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter &nbsp; R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak &nbsp; ↑ · Lompat &nbsp; N · Serang &nbsp; M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>

32
input.css Normal file
View File

@ -0,0 +1,32 @@
/* Gamepad dan ubah tombol (input.js): baris di Pengaturan, dialog "Ubah tombol", cincin fokus untuk navigasi gamepad, toast cadangan. */
.in-row button{flex:0 0 auto;margin-left:12px;border:1px solid #8da8b153;background:#274352;padding:8px 12px;font-size:15px;font-weight:700;white-space:nowrap}
.in-row button:hover{background:#2f5365}
.in-row small{max-width:250px}
.in-pad-help p{line-height:1.45}
html.touch .in-keys-row{display:none}
/* Sesudah memakai gamepad, elemen yang sedang fokus selalu terlihat (fokus lewat skrip tidak selalu memicu :focus-visible). Hilang lagi saat mouse/keyboard dipakai. */
html.gp-nav :is(button,summary,input,select,a):focus{outline:2px solid #fff3d4;outline-offset:4px}
#keymap-dialog{width:540px}
#keymap-dialog h2{font-size:30px;margin:6px 0 4px}
#keymap-dialog p{margin:0}
.km-lead{font-size:15px;line-height:1.35;margin-bottom:12px!important}
.km-grid{display:grid;grid-template-rows:repeat(6,auto);grid-auto-flow:column;grid-template-columns:1fr 1fr;gap:0 26px}
.km-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid #98abb72e;font-size:16px;font-weight:700}
.km-key{flex:0 0 auto;min-width:92px;padding:4px 10px;font:700 15px/20px Rajdhani,sans-serif;text-align:center;background:#ecddbb;color:#102731;border:2px solid transparent}
.km-key:hover{background:#fff1cf}
.km-key.changed{border-color:var(--gold)}
.km-key.listening{background:#102731;color:#fff3d4;border-color:#8fe3d0;animation:km-blink 1s ease-in-out infinite}
@keyframes km-blink{50%{border-color:#102731}}
.km-msg{min-height:40px;margin:10px 0 8px!important;font-size:14px!important;line-height:1.3!important;color:#bdd0d3!important}
.km-msg.bad{color:#f3bdaa!important}
.km-actions{display:flex;gap:8px}
.km-actions button{flex:1;padding:9px 12px;font-size:16px;font-weight:700;border:1px solid #8da8b153;background:#274352}
.km-actions button:hover{background:#2f5365}
.km-actions .km-done{border:0;background:var(--gold);color:#132b37}
.km-actions .km-done:hover{background:#fae0b4}
.km-note{margin-top:10px!important;font-size:12px!important;line-height:1.3!important;color:#91a7b0!important}
.in-toast{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:90;background:#101923ee;border:1px solid #e8b84a;color:#f6eedb;padding:8px 16px;border-radius:6px;font:700 17px Rajdhani,Arial,sans-serif;letter-spacing:.05em;pointer-events:none;transition:opacity .5s}
.in-toast.out{opacity:0}
@media(max-width:620px){.km-grid{grid-template-rows:none;grid-auto-flow:row;grid-template-columns:1fr}#keymap-dialog{padding:18px}}
@media(max-height:560px){#keymap-dialog{padding:16px 22px}#keymap-dialog h2{font-size:26px}.km-lead{font-size:14px;margin-bottom:8px!important}.km-row{padding:2px 0;font-size:15px}.km-key{padding:2px 10px}.km-msg{min-height:34px;margin:6px 0!important}.km-note{margin-top:6px!important}}
@media(prefers-reduced-motion:reduce){.km-key.listening{animation:none}}

450
input.js Normal file
View File

@ -0,0 +1,450 @@
/* Gamepad dan ubah tombol (RENCANA 4.5). Modul mandiri; satu-satunya kait ke kode lama adalah window.__game.input(key, down), sama seperti touch.js.
UBAH TOMBOL (Pemain 1, keyboard): 11 aksi disimpan di localStorage['konoha.keymap.v1'] ({aksi: {c: e.code, l: label}}). Hanya bila berbeda dari bawaan, keydown/keyup
ditangkap di fase capture dan diterjemahkan ke kunci kanonik (a d w s spasi i o p k l j) lewat __game.input; event aslinya dihentikan dan tombol bawaan yang sudah dipindah tidak
diteruskan ke game.js. Tidak aktif di menu depan, dialog, layar VS/cerita, atau kolom teks. Dicadangkan (tidak bisa dipetakan): panah, N, M, koma, titik (Pemain 2), Esc, R,
Enter, Tab, Ctrl/Alt/Cmd, tombol F. Bentrok antaraksi: kedua tombol ditukar dan pemain diberi tahu. Teks bantuan Pengaturan, label tombol di HUD, dan Daftar gerakan menu jeda
mengikuti pemetaan (Daftar gerakan diubah lewat MutationObserver, tanpa menyentuh game.js).
GAMEPAD: polling tiap frame selama ada gamepad (pad pertama = Pemain 1, pad kedua = Pemain 2 bila mode dua pemain aktif; mode dua pemain dilacak lewat pembungkus
__game.startMatch/setP2, atau __game.p2Active bila suatu hari ada). Di arena: stik kiri/D-pad = A D W S, A/Cross = lompat, X/Square = Spasi, Y/Triangle, B/Circle, R1 = I O P,
L1, L2, R2 = K L J, Start = Esc. Di menu depan, dialog, dan layar VS/cerita: D-pad/stik menavigasi (menu depan lewat keydown sintetis yang sudah dipahami menu.js; dialog
lewat pemindahan fokus langsung), A = Enter/klik, B = Esc/tutup. Getaran singkat (vibrationActuator) saat HP pemain berkurang; dapat dimatikan di Pengaturan.
API publik: window.Input (label, keyFor, isCustom, open, reset, pads, rumble). Penyimpanan tambahan: localStorage['konoha.pad.v1'] = {rumble}. */
(() => {
'use strict';
const $ = s => document.querySelector(s);
const game = () => window.__game;
const send = (key, down) => { try { game()?.input?.(key, down); } catch (e) { console.error(e); } };
const tap = key => { send(key, true); send(key, false); };
const esc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const store = {
get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } },
set(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* penyimpanan boleh gagal: pengaturan tetap berlaku selama sesi ini */ } },
del(k) { try { localStorage.removeItem(k); } catch (_) { /* idem */ } }
};
const safe = (name, fn) => { try { fn(); } catch (e) { console.error('input.js (' + name + '):', e); } };
let synth = false; // true selama keydown sintetis milik modul ini dikirim, supaya pendengar sendiri tidak memprosesnya lagi
// ===== Ubah tombol =====
const KEY = 'konoha.keymap.v1';
const NAMED = { Space: 'Spasi', ShiftLeft: 'L-Shift', ShiftRight: 'R-Shift', Backspace: 'Bksp', Delete: 'Del', Insert: 'Ins', PageUp: 'PgUp', PageDown: 'PgDn', NumpadAdd: 'Num +', NumpadSubtract: 'Num -', NumpadMultiply: 'Num *', NumpadDivide: 'Num /', NumpadDecimal: 'Num .', NumpadEqual: 'Num =' };
// Label pendek untuk tampilan: huruf pada keycap bila ada (tata letak apa pun), nama singkat untuk tombol khusus.
function labelOf(code, key) {
if (NAMED[code]) return NAMED[code];
if (/^Numpad\d$/.test(code)) return 'Num ' + code[6];
if (/^Digit\d$/.test(code)) return code[5];
if (/^Key[A-Z]$/.test(code)) return (key && key.length === 1 ? key : code[3]).toUpperCase();
const n = key && key !== 'Unidentified' && key !== 'Dead' ? key : code || 'Tombol';
return (n.length === 1 ? n.toUpperCase() : n).slice(0, 9);
}
// [id, nama di dialog, kunci kanonik yang dipahami game.js, e.code bawaan]
const ACTIONS = [['left', 'Kiri', 'a', 'KeyA'], ['right', 'Kanan', 'd', 'KeyD'], ['jump', 'Lompat', 'w', 'KeyW'], ['crouch', 'Jongkok', 's', 'KeyS'], ['attack', 'Serang', ' ', 'Space'], ['skill1', 'Skill 1', 'i', 'KeyI'],
['skill2', 'Skill 2', 'o', 'KeyO'], ['ultimate', 'Ultimate', 'p', 'KeyP'], ['shop', 'Jurus toko', 'k', 'KeyK'], ['block', 'Blok / parry', 'l', 'KeyL'], ['throw', 'Lempar', 'j', 'KeyJ']].map(([id, name, key, code]) => ({ id, name, key, code }));
const ACT = Object.fromEntries(ACTIONS.map(a => [a.id, a]));
const P2_CODES = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyN', 'KeyM', 'Comma', 'Period']);
const P2_KEYS = new Set(['arrowleft', 'arrowright', 'arrowup', 'n', 'm', ',', '.']);
// Alasan sebuah tombol tidak boleh dipetakan ('' = boleh).
function blocked(code, key) {
if (!code || code === 'Unidentified') return 'tombol tidak dikenali';
if (P2_CODES.has(code) || (key && P2_KEYS.has(key.toLowerCase()))) return 'dipakai Pemain 2';
if (code === 'Escape') return 'dipakai untuk jeda';
if (code === 'KeyR') return 'dipakai untuk mulai ulang';
if (/^(Enter|NumpadEnter|Tab)$/.test(code)) return 'dipakai untuk menu';
if (/^(Control|Alt|Meta|OS)/.test(code) || /^F\d+$/.test(code) || /^(ContextMenu|CapsLock|NumLock|ScrollLock|Pause|PrintScreen|Fn|Lang\d|Convert|NonConvert|KanaMode|IntlRo|IntlYen)/.test(code)) return 'tidak bisa dipakai di game';
return '';
}
let map = {}, byCode = new Map(), custom = false;
const defaults = () => Object.fromEntries(ACTIONS.map(a => [a.id, { c: a.code, l: labelOf(a.code, a.key) }]));
function index() { byCode = new Map(ACTIONS.map(a => [map[a.id].c, a.id])); custom = ACTIONS.some(a => map[a.id].c !== a.code); }
function load() {
map = defaults();
const raw = store.get(KEY);
if (raw) {
try {
const d = JSON.parse(raw), seen = new Set(), next = {};
for (const a of ACTIONS) {
const b = d[a.id];
if (!b || typeof b.c !== 'string' || blocked(b.c) || seen.has(b.c)) throw new Error('pemetaan tidak valid');
seen.add(b.c); next[a.id] = { c: b.c, l: String(b.l || labelOf(b.c, '')).slice(0, 12) };
}
map = next;
} catch (_) { map = defaults(); }
}
index();
}
const persist = () => { if (custom) store.set(KEY, JSON.stringify(map)); else store.del(KEY); };
// --- Penerjemahan keyboard (fase capture, hanya di arena dan hanya bila pemetaan bukan bawaan) ---
const held = new Map(); // e.code -> kunci kanonik yang sedang ditekan lewat pemetaan (dilepas pada keyup apa pun konteksnya)
const swallowUp = new Set(); // keyup yang harus ditelan karena keydown-nya ditangkap dialog pemetaan (cegah klik tombol Spasi di Firefox)
const RAW = new Set(['a', 'd', 'w', 's', ' ', 'i', 'o', 'p', 'k', 'l', 'j']), OLD = new Set(ACTIONS.map(a => a.code));
let gpNav = false;
const field = t => !!t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable);
// Arena yang sedang dimainkan: bukan menu depan, dialog (pengaturan/jeda/ubah tombol), layar VS, atau kartu cerita.
const inArena = () => !document.body.classList.contains('in-menu') && !document.querySelector('dialog[open]') && !window.VS?.active && !window.Story?.active;
const halt = e => { e.preventDefault(); e.stopImmediatePropagation(); };
const dropHeld = () => { for (const k of held.values()) send(k, false); held.clear(); };
addEventListener('keydown', e => {
if (synth) return;
if (gpNav) { gpNav = false; document.documentElement.classList.remove('gp-nav'); }
if (listening) { capture(e); if (e.code) swallowUp.add(e.code); return; }
if (dlg?.open) { if (e.code === 'Escape') { halt(e); closeKeymap(); } return; }
if (!custom || e.isComposing || !inArena() || field(e.target)) return;
const code = e.code || '', key = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (e.target?.tagName === 'BUTTON' && (code === 'Space' || code === 'Enter')) return; // sama seperti game.js: Spasi/Enter pada tombol = klik biasa
const act = byCode.get(code), mod = e.ctrlKey || e.metaKey || e.altKey;
if (act && !mod) {
halt(e);
if (!e.repeat && !held.has(code)) { held.set(code, ACT[act].key); send(ACT[act].key, true); }
} else if (act || RAW.has(key) || OLD.has(code)) { // tombol bawaan yang sudah dipindah (atau kombinasi Ctrl/Alt/Cmd): jangan sampai ke game.js
e.stopImmediatePropagation(); if (!mod) e.preventDefault();
}
}, true);
addEventListener('keyup', e => {
if (synth) return;
const code = e.code || '';
if (held.has(code)) { const k = held.get(code); held.delete(code); halt(e); send(k, false); return; }
if (swallowUp.delete(code)) { halt(e); return; }
if (!custom || !inArena() || field(e.target)) return;
// Keyup tombol bawaan yang sudah dipindah juga ditelan; kalau tidak, game.js akan melepas kunci kanonik yang sedang ditahan lewat tombol barunya.
const key = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (byCode.has(code) || RAW.has(key) || OLD.has(code)) e.stopImmediatePropagation();
}, true);
addEventListener('blur', () => { dropHeld(); swallowUp.clear(); releasePadKeys(); lastKind = null; });
document.addEventListener('visibilitychange', () => { if (document.hidden) { dropHeld(); releasePadKeys(); lastKind = null; } });
// --- Dialog "Ubah tombol" ---
let dlg = null, listening = null;
function ensureDialog() {
if (dlg) return dlg;
dlg = document.createElement('dialog'); dlg.id = 'keymap-dialog'; dlg.setAttribute('aria-labelledby', 'km-title');
dlg.innerHTML = '<div class="dialog-kicker">KONOHA FIGHTING / KONTROL</div><h2 id="km-title">Ubah tombol</h2>'
+ '<p class="km-lead">Pemain 1, keyboard. Pilih aksi, lalu tekan tombol barunya (Esc membatalkan). Bila tombolnya sudah dipakai aksi lain, keduanya ditukar.</p>'
+ '<div class="km-grid" role="group" aria-label="Tombol Pemain 1"></div><p class="km-msg" role="status" aria-live="polite"></p>'
+ '<div class="km-actions"><button type="button" class="km-reset">Atur ulang</button><button type="button" class="km-done">Selesai</button></div>'
+ '<p class="km-note">Tetap: Esc (jeda), R (mulai ulang), dan ← → ↑ N M , . (Pemain 2). Tombol gamepad tidak ikut berubah.</p>';
document.body.append(dlg);
dlg.addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.act) startListen(b.dataset.act);
else if (b.classList.contains('km-reset')) resetMap();
else if (b.classList.contains('km-done')) closeKeymap();
});
dlg.addEventListener('cancel', e => { e.preventDefault(); if (!cancelListen()) closeKeymap(); });
dlg.addEventListener('close', () => { listening = null; swallowUp.clear(); });
return dlg;
}
function renderKeymap() {
const grid = dlg.querySelector('.km-grid'), keep = document.activeElement?.dataset?.act;
grid.innerHTML = ACTIONS.map(a => {
const b = map[a.id], on = listening === a.id;
return `<div class="km-row"><span class="km-name">${esc(a.name)}</span><button type="button" class="km-key${b.c !== a.code ? ' changed' : ''}${on ? ' listening' : ''}" data-act="${a.id}" aria-label="${esc(a.name)}: tombol ${esc(b.l)}. Pilih untuk mengubah">${on ? 'Tekan tombol…' : esc(b.l)}</button></div>`;
}).join('');
const f = listening || keep; if (f) grid.querySelector(`[data-act="${f}"]`)?.focus();
}
function msg(text, bad) { const m = dlg?.querySelector('.km-msg'); if (!m) return; m.textContent = text || ''; m.classList.toggle('bad', !!bad); }
function startListen(act) { listening = act; renderKeymap(); msg(`Tekan tombol baru untuk “${ACT[act].name}”. Esc membatalkan.`); }
function stopListen() { listening = null; if (dlg) renderKeymap(); }
function cancelListen() { if (!listening) return false; stopListen(); msg('Dibatalkan.'); return true; }
function capture(e) { // satu tombol untuk aksi yang sedang didengarkan
halt(e);
if (e.repeat || e.isComposing) return;
const code = e.code || '', key = typeof e.key === 'string' ? e.key : '';
if (code === 'Escape') { cancelListen(); return; }
const label = labelOf(code, key) || 'Tombol', combo = (e.ctrlKey || e.metaKey || e.altKey) && !/^(Control|Alt|Meta|OS)/.test(code);
const why = combo ? 'kombinasi Ctrl / Alt / Cmd tidak bisa dipakai' : blocked(code, key);
if (why) { msg(`${label}: ${why}. Coba tombol lain.`, true); return; }
assign(listening, code, label);
}
function assign(act, code, label) {
const other = byCode.get(code), old = map[act];
if (other === act) { stopListen(); msg(`“${ACT[act].name}” tetap ${old.l}.`); return; }
if (other) map[other] = { ...old }; // bentrok: pasangannya mendapat tombol lama
map[act] = { c: code, l: label };
index(); persist(); stopListen(); applyAll();
msg(other ? `${label} sebelumnya dipakai “${ACT[other].name}”, jadi ditukar: “${ACT[other].name}” sekarang ${old.l}.` : `“${ACT[act].name}” sekarang ${label}.`);
}
function resetMap() { map = defaults(); index(); persist(); listening = null; applyAll(); if (dlg) { renderKeymap(); msg('Kembali ke tombol bawaan.'); } }
function openKeymap() {
ensureDialog(); listening = null; renderKeymap(); msg('');
if (!$('#settings-panel')?.open) game()?.openSettings?.(); // dialog selalu di atas Pengaturan, sehingga game.js menjeda arena selama dialog terbuka
if (!dlg.open) dlg.showModal();
}
function closeKeymap() { listening = null; if (dlg?.open) dlg.close(); }
// --- Tampilan yang mengikuti pemetaan ---
let helpOrig = null;
const upper = id => esc(map[id].l.toUpperCase());
function applyHelp() {
const p = $('#settings-panel .controls-help:not(.in-pad-help) p'); if (!p) return;
if (helpOrig === null) { if (!custom) return; helpOrig = p.innerHTML; }
if (!custom) { p.innerHTML = helpOrig; return; }
const parts = helpOrig.split('<br>'), ok = parts.length >= 6 && /^\s*A \/ D/.test(parts[0]), U = upper;
const lines = [
`${U('left')} / ${U('right')} · Gerak &nbsp; 2× ${U('left')} / ${U('right')} · Lari`,
`${U('jump')} · Lompat (2× ${U('jump')} = lompat ganda) &nbsp; ${U('crouch')} · Menunduk`,
`${U('attack')} · Kombo 3 pukulan &nbsp; ${U('crouch')} ${U('crouch')} + ${U('attack')} · Uppercut &nbsp; ${U('crouch')} → + ${U('attack')} · Tinju maju`,
`${U('skill1')} / ${U('skill2')} · Skill &nbsp; ${U('ultimate')} · Ultimate &nbsp; ${U('shop')} · Jurus toko`,
`${U('block')} · Blok (ketuk = parry) &nbsp; ${U('throw')} · Lempar`
];
p.innerHTML = lines.concat(ok ? parts.slice(5) : parts, ['Tombol Pemain 1 sudah diubah (Ubah tombol… di atas). Tombol P2, Esc, dan R tetap.']).join('<br>');
}
// Petunjuk tombol di kartu skill dan meter ultimate. Teks aslinya diingat; game.js menulis ulang 'P' di meter tiap kali keadaan meter berubah, jadi diamati.
const HUD = [['.skill-card[data-action="attack"] kbd', 'attack'], ['.skill-card[data-action="skill1"] kbd', 'skill1'], ['.skill-card[data-action="skill2"] kbd', 'skill2'], ['.skill-card[data-action="ultimate"] kbd', 'ultimate'], ['#ult-player .ult-kbd', 'ultimate']];
function applyHud() {
for (const [sel, id] of HUD) {
const el = $(sel); if (!el) continue;
if (el.dataset.inOrig === undefined) { if (!custom) continue; el.dataset.inOrig = el.textContent; }
const want = custom && map[id].c !== ACT[id].code ? map[id].l.toUpperCase() : el.dataset.inOrig;
if (el.textContent !== want) el.textContent = want;
}
}
// Daftar gerakan menu jeda dibangun ulang game.js tiap jeda; sesudah itu label tombol Pemain 1 diganti (bagian Pemain 2 dan Lainnya tidak disentuh).
const MV = { A: 'left', D: 'right', W: 'jump', S: 'crouch', L: 'block', J: 'throw', K: 'shop', Spasi: 'attack', I: 'skill1', O: 'skill2', P: 'ultimate', '↓': 'crouch' };
const MV_SECTIONS = /^(Kontrol dasar|Serangan dan skill|Gerakan perintah|Mode )/;
function relabelMoves() {
const body = $('#move-list-body'); if (!body || !custom) return;
for (const sec of body.querySelectorAll('.mv-sec')) {
if (!MV_SECTIONS.test((sec.querySelector('h3')?.firstChild?.textContent || '').trim())) continue;
for (const k of sec.querySelectorAll('kbd.mv-key')) { const orig = k.dataset.inKey ?? (k.dataset.inKey = k.textContent), act = MV[orig]; if (act) k.textContent = map[act].l; }
for (const s of sec.querySelectorAll('small')) { const orig = s.dataset.inHint ?? (s.dataset.inHint = s.textContent.trim()); if (orig === '→ = arah lawan') s.textContent = `→ = arah lawan (${map.left.l} / ${map.right.l})`; }
}
}
function applySummary() {
const el = $('#in-keys-sum'); if (!el) return;
const n = ACTIONS.filter(a => map[a.id].c !== a.code).length;
el.textContent = n ? `${n} dari ${ACTIONS.length} tombol diubah` : 'Bawaan: WASD, Spasi, I O P, K L J';
}
function applyAll() { safe('bantuan', applyHelp); safe('hud', applyHud); safe('ringkasan', applySummary); safe('daftar gerakan', relabelMoves); }
// --- Baris di Pengaturan ---
const PADKEY = 'konoha.pad.v1';
const padPrefs = (() => { let d = {}; try { d = JSON.parse(store.get(PADKEY) || '{}') || {}; } catch (_) { /* simpanan rusak: pakai bawaan */ } return { rumble: d.rumble !== false }; })();
function mountSettings() {
const panel = $('#settings-panel'); if (!panel || $('#in-block')) return;
const block = document.createElement('div'); block.id = 'in-block';
block.innerHTML = '<div class="setting-row in-row in-keys-row"><span>Ubah tombol<small id="in-keys-sum"></small></span><button type="button" id="in-keys-btn">Ubah tombol…</button></div>'
+ '<label class="setting-row in-row"><span>Getaran gamepad<small id="in-pad-sum"></small></span><input type="checkbox" id="in-rumble"></label>';
const anchor = panel.querySelector('.dialog-buttons');
if (anchor) anchor.after(block); else (panel.querySelector('.primary-button') || panel.lastElementChild)?.before(block);
const help = document.createElement('details'); help.className = 'controls-help in-pad-help';
help.innerHTML = '<summary>Kontrol gamepad</summary><p>Stik kiri / D-pad · Gerak (atas = lompat, bawah = menunduk)<br>A (Cross) · Lompat &nbsp; X (Square) · Serang<br>'
+ 'Y (Triangle) · Skill 1 &nbsp; B (Circle) · Skill 2 &nbsp; R1 · Ultimate<br>L1 · Jurus toko &nbsp; L2 · Blok (ketuk = parry) &nbsp; R2 · Lempar<br>Start · Jeda / lanjut<br>'
+ 'Di menu: D-pad / stik memilih, A (Cross) konfirmasi, B (Circle) kembali<br>Dua pemain: gamepad kedua memegang P2 (stik / D-pad gerak, A lompat, X serang, Y / B / R1 = skill 1 / 2 / ultimate)<br>'
+ 'Untuk gamepad bertata letak standar (Xbox, PlayStation, Switch Pro). Tombol gamepad tidak ikut berubah lewat “Ubah tombol”.</p>';
(panel.querySelector('.controls-help') || block).after(help);
$('#in-keys-btn').onclick = openKeymap;
const r = $('#in-rumble'); r.checked = padPrefs.rumble; r.onchange = () => setRumble(r.checked);
padStatus();
}
function setRumble(on) {
padPrefs.rumble = !!on; store.set(PADKEY, JSON.stringify({ rumble: padPrefs.rumble }));
const r = $('#in-rumble'); if (r) r.checked = padPrefs.rumble;
if (padPrefs.rumble) buzz(padList()[0], 160, .6, .6); // contoh singkat supaya terasa
}
// ===== Gamepad =====
const padList = () => { let l = []; try { l = Array.from(navigator.getGamepads?.() || []); } catch (_) { /* getGamepads bisa dilarang kebijakan izin */ } return l.filter(p => p && p.connected && p.buttons && p.buttons.length >= 4); };
const padName = p => { const id = String(p.id || 'Gamepad'), m = id.match(/^(.*?)\s*\(/); return (m && m[1] ? m[1] : id).slice(0, 38); };
function padStatus() {
const el = $('#in-pad-sum'); if (!el) return;
const l = padList(), p = l[0];
el.textContent = p ? 'Terhubung: ' + padName(p) + (l.length > 1 ? ' + ' + (l.length - 1) + ' lagi' : '') + (p.vibrationActuator || p.hapticActuators?.length ? '' : ' (tanpa getaran)') : 'Belum ada gamepad. Hubungkan, lalu tekan sebuah tombol.';
}
function toast(text) {
if (window.Meta?.toast) { window.Meta.toast(text); return; }
const el = document.createElement('div'); el.className = 'in-toast'; el.textContent = text; document.body.append(el);
setTimeout(() => el.classList.add('out'), 2600); setTimeout(() => el.remove(), 3200);
}
// Mode dua pemain: dilacak dari pembungkus (startMatch selalu mematikannya; menu.js memanggil setP2(true) sesudahnya).
let p2 = false, hooked = false;
function hookGame() {
const g = game(); if (hooked || !g) return;
hooked = true;
const sm = g.startMatch, sp = g.setP2;
if (typeof sm === 'function') g.startMatch = function () { p2 = false; return sm.apply(this, arguments); };
if (typeof sp === 'function') g.setP2 = function (on) { p2 = !!on; return sp.apply(this, arguments); };
}
const isP2 = () => { const g = game(); return typeof g?.p2Active === 'function' ? !!g.p2Active() : p2; };
// Kontrol logis dari satu gamepad (tata letak standar). Stik memakai histeresis supaya tidak berkedip di ambang.
const sticks = new Map(), hy = (on, v, hi, lo) => (on ? v > lo : v > hi);
function controls(p) {
const b = i => { const x = p.buttons[i]; return !!x && (x.pressed || x.value > .5); };
const sx = p.axes[0] || 0, sy = p.axes[1] || 0, s = sticks.get(p.index) || {};
s.l = hy(s.l, -sx, .55, .35); s.r = hy(s.r, sx, .55, .35); s.u = hy(s.u, -sy, .6, .4); s.d = hy(s.d, sy, .6, .4); sticks.set(p.index, s);
return { left: s.l || b(14), right: s.r || b(15), up: s.u || b(12), down: s.d || b(13), south: b(0), east: b(1), west: b(2), north: b(3), l1: b(4), r1: b(5), l2: b(6), r2: b(7), start: b(9) };
}
// Kontrol -> kunci arena. Pad pertama = Pemain 1 (kunci kanonik, seperti touch.js); pad kedua = Pemain 2 (kunci P2) hanya di mode dua pemain.
const MAP1 = [['left', 'a'], ['right', 'd'], ['up', 'w'], ['south', 'w'], ['down', 's'], ['west', ' '], ['north', 'i'], ['east', 'o'], ['r1', 'p'], ['l1', 'k'], ['l2', 'l'], ['r2', 'j']];
const MAP2 = [['left', 'arrowleft'], ['right', 'arrowright'], ['up', 'arrowup'], ['south', 'arrowup'], ['west', 'n'], ['north', 'm'], ['east', ','], ['r1', '.']];
const padKeys = new Map(); // indeks pad -> Set kunci yang sedang ditekan olehnya
function releasePadKeys() { for (const s of padKeys.values()) for (const k of s) send(k, false); padKeys.clear(); }
function drive(idx, live, was, slot) {
const prof = slot === 0 ? MAP1 : slot === 1 && isP2() ? MAP2 : [], want = new Set();
for (const [c, k] of prof) if (live[c]) want.add(k);
const cur = padKeys.get(idx) || new Set();
for (const k of cur) if (!want.has(k)) send(k, false);
for (const k of want) if (!cur.has(k)) send(k, true);
padKeys.set(idx, want);
if (live.start && !was.start) tap('escape'); // Esc: jeda / lanjut
}
// Di luar arena: menu depan, dialog, hamparan Meta (toko/karakter/koleksi), layar VS dan kartu cerita.
function kind() {
if (window.VS?.active || window.Story?.active) return 'cards';
if (dlg?.open) return dlg;
const d = document.querySelectorAll('dialog[open]'); if (d.length) return d[d.length - 1];
const mo = $('#meta-overlay'); if (mo && !mo.hidden) return mo;
return document.body.classList.contains('in-menu') ? 'menu' : 'game';
}
const FOCUS = 'button,summary,select,a[href],input[type=checkbox],input[type=range],input[type=radio]';
const shown = el => !el.disabled && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden';
const focusables = root => [...root.querySelectorAll(FOCUS)].filter(shown);
function stepRange(el, sign) {
const min = Number(el.min) || 0, max = el.max === '' ? 100 : Number(el.max), step = Number(el.step) || 1, big = Math.max(step, (max - min) / 20);
const v = Math.min(max, Math.max(min, Number(el.value) + sign * big)); if (v === Number(el.value)) return;
el.value = String(v); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
}
function stepSelect(el, sign) {
const i = Math.min(el.options.length - 1, Math.max(0, el.selectedIndex + sign)); if (i === el.selectedIndex) return;
el.selectedIndex = i; el.dispatchEvent(new Event('change', { bubbles: true }));
}
function focusMove(root, dir) {
const cur = document.activeElement, sign = dir === 'up' || dir === 'left' ? -1 : 1;
if ((dir === 'left' || dir === 'right') && cur && root.contains(cur)) { // geser slider / pilihan; selain itu kiri-kanan = mundur-maju
if (cur.tagName === 'INPUT' && cur.type === 'range') { stepRange(cur, sign); return; }
if (cur.tagName === 'SELECT') { stepSelect(cur, sign); return; }
}
const list = focusables(root); if (!list.length) return;
const i = list.indexOf(cur); list[i < 0 ? (sign > 0 ? 0 : list.length - 1) : (i + sign + list.length) % list.length].focus();
}
function activate(el) {
if (!el || el === document.body || el === document.documentElement) return;
if (el.tagName === 'SELECT') { try { el.showPicker?.(); } catch (_) { /* showPicker perlu gestur pengguna */ } return; }
if (typeof el.click === 'function') el.click();
}
// keydown sintetis ke elemen yang sedang fokus; true bila ada pendengar document (menu.js) yang menanganinya. Pemantau didaftarkan sesudah menu.js, jadi berjalan sesudahnya
// dan sebelum game.js (window), yang selalu memanggil preventDefault pada tombol panah dan akan mengaburkan hasilnya.
function sendKey(key) {
let handled = false;
const probe = e => { handled = e.defaultPrevented; };
document.addEventListener('keydown', probe);
synth = true;
try { (document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, code: key, bubbles: true, cancelable: true })); }
catch (e) { console.error(e); }
finally { synth = false; document.removeEventListener('keydown', probe); }
return handled;
}
const NAV = { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
const mark = () => { gpNav = true; document.documentElement.classList.add('gp-nav'); };
function layerClose(el) {
if (el.id === 'keymap-dialog') { if (!cancelListen()) closeKeymap(); }
else if (el.id === 'settings-panel' || el.id === 'pause-panel') tap('escape'); // game.js: tutup pengaturan / lanjut dari jeda
else if (el.id === 'meta-overlay') el.querySelector('[data-close]')?.click();
else if (el.tagName === 'DIALOG' && el.dispatchEvent(new Event('cancel', { cancelable: true }))) el.close();
}
function uiNav(k, dir) {
mark();
if (k === 'cards') return;
if (k === 'menu') { if (!sendKey(NAV[dir])) focusMove($('#front-menu'), dir); return; }
focusMove(k, dir);
}
function uiAccept(k) {
mark();
if (k === 'cards') sendKey('Enter');
else if (k === 'menu') { if (!sendKey('Enter')) activate(document.activeElement); }
else if (k.contains(document.activeElement)) activate(document.activeElement);
}
function uiBack(k) {
mark();
if (k === 'cards') sendKey('Escape');
else if (k === 'menu') { if ($('#front-menu')?.dataset.screen !== 'home') sendKey('Escape'); }
else layerClose(k);
}
const navState = new Map();
function ui(idx, live, was, k) {
const now = performance.now(), n = navState.get(idx) || {}; navState.set(idx, n);
const dir = live.up ? 'up' : live.down ? 'down' : live.left ? 'left' : live.right ? 'right' : null;
if (!dir) n.dir = null;
else if (dir !== n.dir) { n.dir = dir; n.at = now + 380; uiNav(k, dir); }
else if (now >= n.at) { n.at = now + 110; uiNav(k, dir); }
if (live.south && !was.south) uiAccept(k);
if (live.east && !was.east) uiBack(k);
if (live.start && !was.start) { if (k === 'menu' || k === 'cards') uiAccept(k); else uiBack(k); }
}
// Getaran: dipicu oleh HP pemain yang berkurang (juga K.O.), atau lawan P2 yang kena di mode dua pemain.
function buzz(p, ms, weak, strong) {
if (!p) return;
try {
const a = p.vibrationActuator;
if (a && a.playEffect) a.playEffect(a.type || 'dual-rumble', { startDelay: 0, duration: ms, weakMagnitude: weak, strongMagnitude: strong })?.catch?.(() => {});
else p.hapticActuators?.[0]?.pulse?.(Math.max(weak, strong), ms)?.catch?.(() => {});
} catch (_) { /* getaran hanya pemanis */ }
}
let lastHp = null, lastFoe = '', lastBuzz = 0;
function rumbleCheck(list) {
const g = game(); if (!g?.hero) return;
const hp = g.hero.hp, foe = g.dummy?.state || '', now = performance.now();
if (padPrefs.rumble && lastKind === 'game' && now - lastBuzz > 150) {
if (lastHp !== null && hp < lastHp) { const d = lastHp - hp; buzz(list[0], Math.min(80 + d * 5, hp <= 0 ? 450 : 300), Math.min(.25 + d / 50, 1), hp <= 0 ? 1 : Math.min(.1 + d / 30, 1)); lastBuzz = now; }
else if (list[1] && isP2() && foe !== lastFoe && (foe === 'hurt' || foe === 'down')) { buzz(list[1], 120, .5, .3); lastBuzz = now; }
}
lastHp = hp; lastFoe = foe;
}
let raf = 0, lastKind = null, failed = false, hadPad = false;
const prev = new Map(), sup = new Map();
function stopLoop() {
if (raf) cancelAnimationFrame(raf);
raf = 0; releasePadKeys(); lastKind = null; lastHp = null; prev.clear(); sup.clear(); navState.clear(); sticks.clear();
}
function poll() {
hookGame();
const list = padList(); if (!list.length) { stopLoop(); return; }
const k = kind(), changed = k !== lastKind;
if (changed) { releasePadKeys(); lastKind = k; }
list.forEach((p, slot) => {
const c = controls(p), s = sup.get(p.index) || new Set(); sup.set(p.index, s);
if (changed) for (const n in c) if (c[n]) s.add(n); // yang sedang ditekan saat konteks berganti diabaikan sampai dilepas (A yang menutup dialog tidak boleh jadi lompat)
for (const n of s) if (!c[n]) s.delete(n);
const live = {}; for (const n in c) live[n] = c[n] && !s.has(n);
const was = prev.get(p.index) || {};
if (k === 'game') drive(p.index, live, was, slot); else ui(p.index, live, was, k);
prev.set(p.index, live);
});
rumbleCheck(list);
}
function frame() {
raf = requestAnimationFrame(frame);
try { poll(); } catch (e) { if (!failed) { failed = true; console.error(e); } }
}
const startLoop = () => { if (!raf) raf = requestAnimationFrame(frame); };
function initPads() {
addEventListener('gamepadconnected', () => { const first = !hadPad; hadPad = true; if (first) toast('Gamepad terhubung'); padStatus(); startLoop(); });
addEventListener('gamepaddisconnected', () => { releasePadKeys(); padStatus(); if (hadPad && !padList().length) { hadPad = false; toast('Gamepad terputus'); } });
addEventListener('pointerdown', () => { if (gpNav) { gpNav = false; document.documentElement.classList.remove('gp-nav'); } }, true);
if (padList().length) { hadPad = true; padStatus(); startLoop(); }
}
function observe(target, opts, fn) { if (target) new MutationObserver(fn).observe(target, opts); }
// ===== Mulai =====
safe('memuat', load);
safe('kait', hookGame);
safe('pengaturan', mountSettings);
safe('tampilan', applyAll);
safe('daftar gerakan', () => observe($('#move-list-body'), { childList: true }, relabelMoves));
safe('meter ultimate', () => observe($('#ult-player .ult-kbd'), { childList: true, characterData: true, subtree: true }, applyHud));
safe('gamepad', initPads);
window.Input = {
actions: ACTIONS.map(a => ({ id: a.id, name: a.name, key: a.key })),
label: (id, caps) => { const b = map[id]; return b ? (caps ? b.l.toUpperCase() : b.l) : ''; },
keyFor: id => ACT[id]?.key || '',
isCustom: () => custom,
open: openKeymap,
reset: resetMap,
pads: () => padList().map(p => ({ index: p.index, id: p.id, mapping: p.mapping })),
get rumble() { return padPrefs.rumble; },
set rumble(v) { setRumble(v); }
};
})();