fighting-konoha/touchlayout.js

183 lines
12 KiB
JavaScript

/* Tata letak tombol sentuh yang bisa diatur (RENCANA 4.6). Hanya aktif di html.touch.
Pengaturan > "Tata letak tombol" > ATUR membuka editor layar penuh: sebuah <dialog> modal di atas Pengaturan (jadi game tetap beku, sama seperti saat
Pengaturan terbuka). Salinan pad arah (.dpad), tombol aksi (.action-pad), dan panel tambahan (.extra-pad) dibungkus kotak yang bisa diseret (pindah)
atau ditarik pegangan sudutnya (ukuran). Salinan itu tidak punya pendengar apa pun dan dokumen lain tidak aktif selama editor terbuka, jadi tidak ada
input yang terkirim ke game. touch.js tidak diubah: hasilnya hanya variabel CSS --tl-x/--tl-y/--tl-s dan kelas .tl-xf pada elemen asli (transformasi
ada di touchlayout.css; titik tumpunya sudut yang dekat tepi layar, jadi pembesaran selalu menjauhi tepi).
Simpanan: localStorage['konoha.touchlayout.v1'] = {v:1, g:{dpad|action|extra:{x,y,s}}}; x,y = geseran sebagai pecahan lebar/tinggi layar, s = skala.
Posisi dijepit ke dalam layar memakai kotak nyata hasil ukur (juga saat ukuran layar berubah); jepitan itu tidak menimpa nilai simpanan. */
(() => {
'use strict';
const html = document.documentElement;
if (!html.classList.contains('touch')) return;
const root = document.getElementById('touch-controls'), panel = document.getElementById('settings-panel');
if (!root || !panel) return;
const KEY = 'konoha.touchlayout.v1', S_MIN = .6, S_MAX = 1.8, GAP = 4, BAR = 46; // BAR = tinggi bilah editor; area tombol dimulai di bawahnya
const GROUPS = [
{ id: 'dpad', sel: '.dpad', name: 'ARAH', label: 'pad arah', grip: 'tr' },
{ id: 'action', sel: '.action-pad', name: 'AKSI', label: 'tombol aksi', grip: 'tl' },
{ id: 'extra', sel: '.extra-pad', name: 'TAMBAHAN', label: 'tombol tambahan', grip: 'tl' },
].map(g => ({ ...g, node: root.querySelector(g.sel) })).filter(g => g.node);
if (!GROUPS.length) return;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)), r4 = v => Math.round(v * 1e4) / 1e4, num = v => typeof v === 'number' && isFinite(v);
const blank = () => ({ x: 0, y: 0, s: 1 });
const isBlank = st => GROUPS.every(g => !st[g.id].x && !st[g.id].y && st[g.id].s === 1);
function read() {
let raw = null;
try { raw = JSON.parse(localStorage.getItem(KEY)); } catch (_) { /* simpanan rusak atau diblokir: pakai tata letak bawaan */ }
const st = {};
for (const g of GROUPS) {
const v = raw && raw.v === 1 && raw.g && raw.g[g.id], o = blank();
if (v && num(v.x) && num(v.y) && num(v.s)) { o.x = clamp(v.x, -1, 1); o.y = clamp(v.y, -1, 1); o.s = clamp(v.s, S_MIN, S_MAX); }
st[g.id] = o;
}
return st;
}
function write(st) { try { if (isBlank(st)) localStorage.removeItem(KEY); else localStorage.setItem(KEY, JSON.stringify({ v: 1, g: st })); } catch (_) { /* tidak tersimpan; tata letak berlaku sampai halaman ditutup */ } }
let state = read();
let ed = null; // editor yang sedang terbuka: { dlg, snap, parts: {id: {g, clone, frame, shown}}, z }
const cur = id => ({ dx: state[id].x * innerWidth, dy: state[id].y * innerHeight, s: state[id].s });
// Batas area tombol: layar dikurangi tepi aman (takik), jarak kecil ke tepi, dan bilah editor di atas.
let area = null;
addEventListener('resize', () => { area = null; });
function bounds() {
if (area) return area;
const p = document.createElement('div');
p.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)';
document.body.append(p);
const c = getComputedStyle(p), n = k => parseFloat(c[k]) || 0;
area = { l: n('paddingLeft') + GAP, t: n('paddingTop') + BAR, r: innerWidth - n('paddingRight') - GAP, b: innerHeight - n('paddingBottom') - GAP };
p.remove();
return area;
}
// Menerapkan {dx, dy, s} pada node, mengukur kotak nyatanya, lalu menjepit ukuran dan posisi ke dalam batas. Mengembalikan nilai akhir.
// Node tersembunyi (mis. kontrol asli saat di menu) tidak bisa diukur: nilai dipasang apa adanya dan dijepit nanti saat tampil (lihat ResizeObserver).
function fit(node, v, b) {
const o = { dx: v.dx, dy: v.dy, s: clamp(v.s, S_MIN, S_MAX) };
const put = () => { node.style.setProperty('--tl-x', o.dx + 'px'); node.style.setProperty('--tl-y', o.dy + 'px'); node.style.setProperty('--tl-s', String(o.s)); };
put();
let r = node.getBoundingClientRect();
if (!r.width || !r.height) return o;
const f = Math.min(1, (b.r - b.l) / r.width, (b.b - b.t) / r.height);
if (f < 1) { o.s = Math.max(S_MIN, o.s * f); put(); r = node.getBoundingClientRect(); }
o.dx += r.left < b.l ? b.l - r.left : r.right > b.r ? b.r - r.right : 0;
o.dy += r.top < b.t ? b.t - r.top : r.bottom > b.b ? b.b - r.bottom : 0;
put();
return o;
}
// Elemen asli: pasang tata letak simpanan; kelas dan variabel dilepas seluruhnya bila nilainya bawaan (tampilan sama persis seperti sebelum fitur ini).
function applyReal() {
const b = bounds();
for (const g of GROUPS) {
const st = state[g.id];
if (!st.x && !st.y && st.s === 1) { g.node.classList.remove('tl-xf'); for (const k of ['--tl-x', '--tl-y', '--tl-s']) g.node.style.removeProperty(k); continue; }
g.node.classList.add('tl-xf');
fit(g.node, cur(g.id), b);
}
}
applyReal();
// Kontrol asli tersembunyi di menu dan baru bisa diukur saat tampil di arena: ResizeObserver menjepit ulang pada saat itu (sebelum digambar) dan saat layar berubah.
if (typeof ResizeObserver === 'function') new ResizeObserver(() => applyReal()).observe(root); else addEventListener('resize', applyReal);
// Sabuk pengaman: selama editor terbuka, sentuhan tidak boleh sampai ke tombol asli (dokumen sudah tidak aktif karena dialog modal).
root.addEventListener('pointerdown', e => { if (ed) { e.stopPropagation(); e.preventDefault(); } }, true);
// ---- editor ----
const store = (p, o) => { state[p.g.id] = { x: r4(o.dx / innerWidth), y: r4(o.dy / innerHeight), s: r4(o.s) }; };
function paint(p, v) {
const o = fit(p.clone, v, bounds());
p.shown = o;
const r = p.clone.getBoundingClientRect();
Object.assign(p.frame.style, { left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' });
p.frame.querySelector('.tl-name').textContent = p.g.name + (Math.abs(o.s - 1) > .005 ? ' ' + Math.round(o.s * 100) + '%' : '');
return o;
}
function repaint() { area = null; if (ed) for (const id in ed.parts) paint(ed.parts[id], cur(id)); }
function reset() { for (const g of GROUPS) { state[g.id] = blank(); paint(ed.parts[g.id], cur(g.id)); } }
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); finish(false); } } // Esc milik editor, bukan milik game (yang akan menutup Pengaturan)
// Satu bagian editor: salinan visual tombol (tanpa pendengar, tanpa input) + kotak seret di atasnya.
function part(g, stage) {
const clone = g.node.cloneNode(true);
clone.classList.add('tl-xf', 'tl-clone'); clone.inert = true;
for (const n of clone.querySelectorAll('.cooldown-wipe,.cooldown-num')) n.remove();
for (const n of [clone, ...clone.querySelectorAll('*')]) { n.classList.remove('pressed', 'active', 'recharged', 'on-cooldown'); for (const a of ['data-key', 'data-slot', 'tabindex', 'aria-label', 'aria-hidden']) n.removeAttribute(a); }
const frame = document.createElement('div');
frame.className = 'tl-box'; frame.tabIndex = 0; frame.setAttribute('role', 'group');
frame.setAttribute('aria-label', g.label + ': panah menggeser, plus dan minus mengubah ukuran');
frame.innerHTML = '<span class="tl-name"></span><i class="tl-grip ' + g.grip + '"></i>';
stage.append(clone, frame);
const p = { g, clone, frame, shown: cur(g.id) };
let d = null; // seretan yang sedang berjalan pada kotak ini (tiap kotak punya jarinya sendiri)
frame.addEventListener('pointerdown', e => {
if (d || (e.pointerType === 'mouse' && e.button !== 0)) return;
e.preventDefault();
const r = clone.getBoundingClientRect(), ax = g.grip === 'tl' ? r.right : r.left, ay = r.bottom; // titik tumpu = sudut yang tidak ikut bergerak saat ukuran berubah
d = { id: e.pointerId, grip: !!e.target.closest('.tl-grip'), x: e.clientX, y: e.clientY, v: { ...p.shown }, ax, ay, len: Math.max(24, Math.hypot(e.clientX - ax, e.clientY - ay)) };
try { frame.setPointerCapture(e.pointerId); } catch (_) { /* tanpa capture seretan tetap jalan selama jari di atas kotak */ }
frame.classList.add('on'); frame.style.zIndex = String(++ed.z);
});
frame.addEventListener('pointermove', e => {
if (!d || e.pointerId !== d.id) return;
const v = d.grip ? { dx: d.v.dx, dy: d.v.dy, s: d.v.s * Math.hypot(e.clientX - d.ax, e.clientY - d.ay) / d.len } : { dx: d.v.dx + e.clientX - d.x, dy: d.v.dy + e.clientY - d.y, s: d.v.s };
store(p, paint(p, v));
});
const end = e => { if (d && e.pointerId === d.id) { d = null; frame.classList.remove('on'); } };
for (const t of ['pointerup', 'pointercancel', 'lostpointercapture']) frame.addEventListener(t, end);
frame.addEventListener('keydown', e => { // papan ketik: panah menggeser (Shift = lebih jauh), + dan - mengubah ukuran
const step = e.shiftKey ? 24 : 8, move = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
if (!move && !['+', '=', '-', '_'].includes(e.key)) return;
e.preventDefault(); e.stopPropagation();
const v = { ...p.shown };
if (move) { v.dx += move[0]; v.dy += move[1]; } else v.s *= ['+', '='].includes(e.key) ? 1.06 : 1 / 1.06;
store(p, paint(p, v));
});
return p;
}
function open() {
if (ed) return;
const dlg = document.createElement('dialog');
dlg.id = 'tl-editor'; dlg.className = document.body.classList.contains('in-menu') ? 'tl-over-menu' : '';
dlg.setAttribute('aria-label', 'Atur tata letak tombol sentuh');
dlg.innerHTML = '<div class="tl-bar"><strong>ATUR TOMBOL</strong><button type="button" data-tl="reset">ATUR ULANG</button><button type="button" data-tl="cancel">BATAL</button><button type="button" data-tl="done" class="tl-done">SELESAI</button></div><div class="tl-stage"><p class="tl-hint">Seret kotak untuk memindahkan.<br>Tarik lingkaran di sudutnya untuk mengubah ukuran.</p></div>';
ed = { dlg, snap: JSON.parse(JSON.stringify(state)), parts: {}, z: 2 };
try {
const stage = dlg.querySelector('.tl-stage');
for (const g of GROUPS) ed.parts[g.id] = part(g, stage);
dlg.addEventListener('click', e => { const act = e.target.closest('[data-tl]')?.dataset.tl; if (act === 'reset') reset(); else if (act === 'cancel') finish(false); else if (act === 'done') finish(true); });
dlg.addEventListener('close', () => finish(false)); // ditutup peramban (mis. tombol Kembali di Android): perubahan dibuang
document.body.append(dlg);
html.classList.add('tl-editing'); // menyembunyikan Pengaturan yang masih terbuka di bawah editor
addEventListener('keydown', onKey, true); addEventListener('resize', repaint);
dlg.showModal();
repaint();
dlg.querySelector('.tl-done').focus();
} catch (err) { console.error(err); finish(false); }
}
function finish(save) {
if (!ed) return;
const { dlg, snap } = ed; ed = null;
removeEventListener('keydown', onKey, true); removeEventListener('resize', repaint);
if (save) write(state); else state = snap;
html.classList.remove('tl-editing'); // sebelum dlg.close(): fokus harus kembali ke tombol ATUR yang harus sudah terlihat
if (dlg.open) dlg.close();
dlg.remove();
applyReal(); status();
}
// Baris di Pengaturan, di atas tombol Tes audio / Layar penuh (setelah baris kode cheat dari meta.js).
function status() { const m = panel.querySelector('#tl-msg'); if (m) m.textContent = isBlank(state) ? 'Geser dan ubah ukuran pad arah dan tombol aksi' : 'Tata letak kustom aktif'; }
if (!panel.querySelector('#tl-row')) {
const row = document.createElement('div');
row.className = 'setting-row'; row.id = 'tl-row';
row.innerHTML = '<span>Tata letak tombol<small id="tl-msg"></small></span><button type="button" class="kf-set-btn" id="tl-open">ATUR</button>';
(panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(row);
row.querySelector('#tl-open').onclick = open;
status();
}
window.TouchLayout = { open, get editing() { return !!ed; }, get layout() { return JSON.parse(JSON.stringify(state)); } };
})();