From 98e996a6b7dd3d407162a8c62d7bf4cc343bd502 Mon Sep 17 00:00:00 2001 From: rizkyaz0 Date: Mon, 5 Oct 2026 13:44:54 +0700 Subject: [PATCH] Gamepad dan ubah tombol (RENCANA 4.5): modul input.js + input.css. Gamepad: stik/D-pad dan tombol aksi memanggil __game.input (pad kedua = P2 di mode dua pemain), Start = jeda, navigasi menu depan/dialog/layar VS dengan D-pad, A, B, getaran saat kena (dapat dimatikan), toast 'Gamepad terhubung', pemetaan tampil di Pengaturan. Ubah tombol: dialog untuk 11 aksi Pemain 1 (localStorage, deteksi bentrok dengan tukar, Atur ulang, tombol P2/Esc/R dicadangkan); penerjemahan di fase capture; bantuan kontrol, label HUD, dan Daftar gerakan menu jeda mengikuti pemetaan. Catatan mesin diperbarui (mode ringan: diuji manual oleh pengguna) --- docs/ENGINE_NOTES.md | 1 + index.html | 4 +- input.css | 32 +++ input.js | 450 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 input.css create mode 100644 input.js diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 248a9a5..e90cf16 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -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 `
Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat (2× W = lompat ganda)   S · Menunduk
SPACE · Kombo 3 pukulan   ↓ ↓ + SPACE · Uppercut   ↓ → + SPACE · Tinju maju
I / O · Skill   P · Ultimate   K · Jurus toko
L · Blok (ketuk = parry)   J · Lempar
Esc · Jeda, berisi daftar gerakan karakter   R · Mulai ulang
Dua pemain (P2): ← → · Gerak   ↑ · Lompat   N · Serang   M / , / . · Skill 1 / 2 / Ultimate
Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.

KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
-
+
diff --git a/input.css b/input.css new file mode 100644 index 0000000..b19725a --- /dev/null +++ b/input.css @@ -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}} diff --git a/input.js b/input.js new file mode 100644 index 0000000..8cdc1ac --- /dev/null +++ b/input.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = '
KONOHA FIGHTING / KONTROL

Ubah tombol

' + + '

Pemain 1, keyboard. Pilih aksi, lalu tekan tombol barunya (Esc membatalkan). Bila tombolnya sudah dipakai aksi lain, keduanya ditukar.

' + + '

' + + '
' + + '

Tetap: Esc (jeda), R (mulai ulang), dan ← → ↑ N M , . (Pemain 2). Tombol gamepad tidak ikut berubah.

'; + 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 `
${esc(a.name)}
`; + }).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('
'), ok = parts.length >= 6 && /^\s*A \/ D/.test(parts[0]), U = upper; + const lines = [ + `${U('left')} / ${U('right')} · Gerak   2× ${U('left')} / ${U('right')} · Lari`, + `${U('jump')} · Lompat (2× ${U('jump')} = lompat ganda)   ${U('crouch')} · Menunduk`, + `${U('attack')} · Kombo 3 pukulan   ${U('crouch')} ${U('crouch')} + ${U('attack')} · Uppercut   ${U('crouch')} → + ${U('attack')} · Tinju maju`, + `${U('skill1')} / ${U('skill2')} · Skill   ${U('ultimate')} · Ultimate   ${U('shop')} · Jurus toko`, + `${U('block')} · Blok (ketuk = parry)   ${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('
'); + } + // 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 = '
Ubah tombol
' + + ''; + 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 = 'Kontrol gamepad

Stik kiri / D-pad · Gerak (atas = lompat, bawah = menunduk)
A (Cross) · Lompat   X (Square) · Serang
' + + 'Y (Triangle) · Skill 1   B (Circle) · Skill 2   R1 · Ultimate
L1 · Jurus toko   L2 · Blok (ketuk = parry)   R2 · Lempar
Start · Jeda / lanjut
' + + 'Di menu: D-pad / stik memilih, A (Cross) konfirmasi, B (Circle) kembali
Dua pemain: gamepad kedua memegang P2 (stik / D-pad gerak, A lompat, X serang, Y / B / R1 = skill 1 / 2 / ultimate)
' + + 'Untuk gamepad bertata letak standar (Xbox, PlayStation, Switch Pro). Tombol gamepad tidak ikut berubah lewat “Ubah tombol”.

'; + (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); } + }; +})();