fighting-konoha/input.js

451 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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