451 lines
31 KiB
JavaScript
451 lines
31 KiB
JavaScript
/* 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 = '<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 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('<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 X (Square) · Serang<br>'
|
||
+ 'Y (Triangle) · Skill 1 B (Circle) · Skill 2 R1 · Ultimate<br>L1 · Jurus toko L2 · Blok (ketuk = parry) 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); }
|
||
};
|
||
})();
|