/* 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); } }; })();