fighting-konoha/mystery.js

180 lines
14 KiB
JavaScript

/* Slot misteri yang hidup (RENCANA 3.7). Lima petak 'locked-N' di layar pilih karakter (menu.js) menjadi sistem petunjuk bertahap.
Data: content/mystery.js (window.MYSTERY). Kemajuan dibaca langsung dari Meta.save. Gaya: mystery.css. Tanpa gambar baru:
siluet digambar sebagai SVG sebaris (bentuk tubuh disalin dari assets/menu/locked.svg), makin tajam tiap petunjuk terbuka,
dan sebuah benda khas muncul samar lalu jelas di dua tahap terakhir.
menu.js memanggil: Mystery.panel (panel pratinjau slot yang disorot), decorate (petak roster, tiap selesai render), preview
(teks di bawah roster saat sorotan berpindah) dan notice (pesan saat slot terkunci ditekan). Slot tetap tidak bisa dipilih.
Fungsi publik tidak pernah melempar galat; bila gagal, layar kembali ke tampilan slot terkunci yang lama.
Semua petunjuk terbuka = pencapaian PENASARAN BERAT (meta.js). ?mystery=N (atau all) di URL hanya mempratinjau tahap N pada
semua slot, tanpa menyimpan apa pun dan tanpa membuka pencapaian. Petunjuk yang sudah dilihat diingat di
localStorage['konoha.mystery.seen'] (hanya untuk titik BARU dan toast; bukan bagian Meta.save). */
(() => {
'use strict';
const SEEN_KEY = 'konoha.mystery.seen', ACH_ALL = 'penasaran_berat', FALLBACK_COLOR = '#7fe0ff';
const PREVIEW = (() => { try { const v = new URLSearchParams(location.search).get('mystery'); if (v === 'all') return 99; const n = v === null || v === '' ? -1 : parseInt(v, 10); return n >= 0 ? n : null; } catch (_) { return null; } })();
const num = v => (typeof v === 'number' && isFinite(v) ? v : 0);
const len = a => (Array.isArray(a) ? a.length : 0);
const count = (o, ok) => (o && typeof o === 'object' ? Object.keys(o).filter(k => o[k] && ok(k)).length : 0);
const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const save = () => window.Meta?.save || {};
const enabled = () => Array.isArray(window.MYSTERY) && window.MYSTERY.length > 0;
const slotIndex = id => { const m = /^locked-(\d+)$/.exec(id || ''); return m ? +m[1] : -1; };
// Syarat yang dikenali data (need.type). read = nilai sekarang dari Meta.save; brief = tujuan satu kalimat (l = sisa, n = target);
// hint = cara mencapainya (opsional); avail = false bila fitur pendukungnya tidak ada (tahap itu dilewati).
const GOALS = {
wins: { read: s => num(s.stats?.wins), brief: l => `Menang ${l} kali lagi` },
matches: { read: s => num(s.stats?.wins) + num(s.stats?.losses), brief: l => `Main ${l} pertandingan lagi` },
perfects: { read: s => num(s.stats?.perfects), brief: l => `Menang bersih ${l} kali lagi`, hint: 'Menang tanpa kehilangan satu ronde pun.' },
combo: { read: s => num(s.stats?.bestCombo), brief: (l, n) => `Capai kombo ${n} pukulan` },
survival: { read: s => num(s.best?.survival), brief: (l, n) => `Menang ${n}x beruntun (Survival)`, hint: 'Yang dihitung rekor beruntun terbaikmu di Survival.' },
arcade: { read: s => (s.ach?.arcade_clear ? 1 : 0), brief: () => 'Tamatkan mode Arcade' },
ach: { read: s => count(s.ach, k => k !== ACH_ALL), brief: l => `Buka ${l} pencapaian lagi` },
eggs: { read: s => count(s.eggs, k => !/^(code|dialog):/.test(k)), brief: l => `Temukan ${l} kejutan lagi`, hint: 'Coba ketik, ketuk, atau klik sesuatu yang tidak biasa.' },
pairs: { read: s => count(s.eggs, k => k.startsWith('dialog:')), brief: l => `Temukan ${l} dialog rahasia lagi`, hint: 'Pertemukan pasangan lawan tertentu di mode Versus.', avail: () => Object.keys(window.DIALOGS?.rahasia || {}).length > 0 },
shop: { read: s => Math.max(0, len(s.owned?.skin) - 1) + len(s.owned?.move) + len(s.owned?.alt), brief: l => `Beli ${l} barang toko lagi`, hint: 'Skin, jurus toko, atau varian skill.' },
coins: { read: s => num(s.earned), brief: l => `Kumpulkan ${l} koin lagi` }
};
const need = st => Math.max(1, Math.floor(num(st.need.n)));
const hex = c => (/^#[0-9a-f]{6}$/i.test(c || '') ? c : FALLBACK_COLOR);
// Keadaan satu slot: stage = jumlah petunjuk terbuka (berurutan), next = syarat petunjuk berikutnya (null bila semua terbuka).
function compute(i, s) {
const d = window.MYSTERY[i];
if (!d || !Array.isArray(d.stages)) return null;
const stages = d.stages.filter(st => st && st.need && GOALS[st.need.type] && (!GOALS[st.need.type].avail || GOALS[st.need.type].avail()));
if (!stages.length) return null;
let open = 0;
while (open < stages.length && GOALS[stages[open].need.type].read(s) >= need(stages[open])) open++;
if (PREVIEW !== null) open = Math.min(PREVIEW, stages.length);
const nx = stages[open], g = nx && GOALS[nx.need.type], n = nx ? need(nx) : 0, cur = nx ? Math.min(n, g.read(s)) : 0;
return { i, id: String(d.id || 'm' + i), color: hex(d.color), shape: d.shape, max: stages.length, stage: open, clues: stages.slice(0, open),
next: nx ? { n, cur, brief: g.brief(n - cur, n, cur), hint: g.hint || '', pct: Math.round(cur / n * 100) } : null };
}
// Petak yang punya data saja; locked = jumlah petak terkunci di roster (menu.js) supaya "N dari M" sesuai yang tampil.
function stateOf(id, locked) {
const i = slotIndex(id), cap = Math.min(locked == null ? Infinity : locked, enabled() ? window.MYSTERY.length : 0);
if (i < 0 || i >= cap) return null;
const st = compute(i, save());
if (st) { st.n = i + 1; st.total = cap; }
return st;
}
function states(locked) {
const cap = Math.min(locked == null ? Infinity : locked, enabled() ? window.MYSTERY.length : 0), s = save();
return Array.from({ length: cap }, (_, i) => { const st = compute(i, s); if (st) { st.n = i + 1; st.total = cap; } return st; });
}
// ---- siluet: tubuh dari locked.svg + satu benda per slot (muncul samar di tahap kedua terakhir, jelas di tahap akhir) ----
const BODY = 'm50 58 9-27 18-12 20 8 17 24-6 7 2 24-12 15-1 13 28 12 12 13 13 45H10l13-45 12-13 28-12-1-13-12-15-2-24Z';
const RING = 'fill="none" stroke="currentColor" stroke-width="3.2"';
const SHAPES = {
palu: '<path d="M114.6 107.5 109.4 104.5 135.4 58.5 140.6 61.5Z"/><path d="M154 58 146 72 122 58 130 44Z"/><circle cx="111" cy="107" r="7"/>',
gunting: `<path d="M126.8 105 121.2 103 147 33.7 149 34.3Z"/><path d="M145.2 105 150.8 103 125 33.7 123 34.3Z"/><circle cx="120" cy="111" r="6.5" ${RING}/><circle cx="152" cy="111" r="6.5" ${RING}/>`,
pengeras: '<path d="M112 68 154 44 154 100 112 80Z"/><path d="M104 69 112 68 112 80 104 79Z"/><path d="M122 86 132 90 128 106 119 103Z"/>',
helm: '<path d="M42 48A39 33 0 0 1 120 48Z"/><path d="M34 48H128L126 56H36Z"/><path d="M77 14H85V30H77Z"/>',
ponsel: '<rect x="118" y="46" width="30" height="54" rx="5"/><path d="M124 38Q133 30 142 38M118 32Q133 20 148 32" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>'
};
const VARIANTS = [[1, 1], [-1, .95], [1, 1], [-1, 1.04], [1, .93]]; // [arah, lebar] supaya tiap slot punya potongan tubuh berbeda
const GLYPHS = ['?', '??', '?!', '!?', '!'];
const glyph = st => GLYPHS[st.stage >= st.max ? 4 : Math.min(3, Math.round(st.stage / st.max * 4))];
function sil(st) {
const [dir, w] = VARIANTS[st.i % VARIANTS.length], cls = st.stage >= st.max ? 'ms-f' : st.stage === st.max - 1 ? 'ms-g' : '';
return `<svg class="ms-svg ${cls}" viewBox="0 0 160 180" preserveAspectRatio="xMidYMax meet" aria-hidden="true" focusable="false"><g transform="translate(80 0) scale(${(dir * w).toFixed(2)} 1) translate(-80 0)"><path class="ms-body" d="${BODY}"/><g class="ms-acc">${SHAPES[st.shape] || ''}</g></g></svg>`;
}
// --ms-p (0..1) = tahap/jumlah tahap: CSS memakainya untuk blur, kekuatan garis tepi, dan kecerahan tanda tanya.
const varMap = st => ({ '--ms-c': st.color, '--ms-a': st.color + '44', '--ms-b': st.color + '00', '--ms-p': (st.max ? st.stage / st.max : 0).toFixed(3) });
const vars = st => Object.entries(varMap(st)).map(([k, v]) => k + ':' + v).join(';');
// ---- petunjuk yang sudah dilihat (titik BARU dan toast) ----
let seenCache = null;
function seenMap() { if (!seenCache) { try { const v = JSON.parse(localStorage.getItem(SEEN_KEY) || '{}'); seenCache = v && typeof v === 'object' ? v : {}; } catch (_) { seenCache = {}; } } return seenCache; }
function seenSave() { try { localStorage.setItem(SEEN_KEY, JSON.stringify(seenCache || {})); } catch (_) {} }
// s = tahap yang sudah dilihat di panel; t = tahap yang sudah diberitahukan lewat toast. Keduanya turun ikut bila simpanan di-reset.
function see(st) { if (PREVIEW !== null) return; const m = seenMap(), e = m[st.id]; if (e && e.s === st.stage && e.t === st.stage) return; m[st.id] = { s: st.stage, t: st.stage }; seenSave(); }
function announce(list) {
if (PREVIEW !== null) return;
const m = seenMap(); let fresh = 0, changed = false;
for (const st of list) {
if (!st) continue;
const e = m[st.id] || (m[st.id] = { s: 0, t: 0 });
if (st.stage > e.t) fresh += st.stage - e.t;
if (e.t !== st.stage) { e.t = st.stage; changed = true; }
}
if (changed) seenSave();
if (fresh) window.Meta?.toast?.(fresh === 1 ? 'Petunjuk baru terbuka di slot misteri!' : fresh + ' petunjuk baru terbuka di slot misteri!');
}
function evaluate(list) {
if (PREVIEW !== null) return;
const live = list.filter(Boolean);
if (live.length && live.every(st => st.stage >= st.max)) window.Meta?.unlock?.(ACH_ALL);
}
function overview(locked) {
const live = states(locked).filter(Boolean);
if (!live.length) return '';
return `${live.length} slot misteri · ${live.reduce((a, st) => a + st.stage, 0)} dari ${live.reduce((a, st) => a + st.max, 0)} petunjuk terbuka`;
}
// ---- tampilan ----
// Petak roster: img tetap di alur (hanya disembunyikan lewat CSS) sehingga ukuran grid tidak berubah.
function decorateTile(tile, st, fresh) {
const lv = st.stage, done = lv >= st.max;
tile.classList.add('ms-tile'); tile.classList.toggle('ms-on', lv > 0); tile.classList.toggle('ms-done', done);
for (const [k, v] of Object.entries(varMap(st))) tile.style.setProperty(k, v);
tile.setAttribute('aria-label', `Slot misteri ${st.n} dari ${st.total}, petunjuk terbuka ${lv} dari ${st.max}, terkunci`);
const badge = tile.querySelector(':scope > b'), label = tile.querySelector(':scope > span');
if (badge) badge.textContent = lv + '/' + st.max;
if (label) label.textContent = done ? 'LENGKAP' : lv ? 'MISTERI' : 'LOCKED';
tile.querySelectorAll('.ms-sil,.ms-q,.ms-new').forEach(e => e.remove());
if (lv) tile.querySelector('img')?.insertAdjacentHTML('afterend', `<i class="ms-sil" aria-hidden="true">${sil(st)}</i><i class="ms-q" aria-hidden="true">${glyph(st)}</i>`);
if (fresh) tile.insertAdjacentHTML('beforeend', '<i class="ms-new" aria-hidden="true"></i>');
}
// Panel pratinjau slot yang disorot (menggantikan fighterPanel untuk petak terkunci); '' bila id bukan slot misteri.
function panel(id, side, locked) {
try {
const st = stateOf(id, locked); if (!st) return '';
const art = st.stage ? `${sil(st)}<b class="ms-glyph" aria-hidden="true">${glyph(st)}</b>` : '<img src="assets/menu/locked.svg" alt="">';
const dots = Array.from({ length: st.max }, (_, k) => `<i${k < st.stage ? ' class="on"' : ''}></i>`).join('');
const clues = st.clues.length ? st.clues.map(c => `<li><b>${esc(c.tag || 'PETUNJUK')}</b>${esc(c.text)}</li>`).join('') : '<li class="ms-empty">Belum ada petunjuk. Siapa dia?</li>';
const nx = st.next;
const next = nx ? `<small>PETUNJUK BERIKUTNYA</small><strong>${esc(nx.brief)}</strong>${nx.hint ? `<em>${esc(nx.hint)}</em>` : ''}<span class="ms-prog"><i><u style="width:${nx.pct}%"></u></i><b>${nx.cur}/${nx.n}</b></span>` : '<small>SEMUA PETUNJUK TERBUKA</small><strong>Tinggal menunggu giliran tampil.</strong>';
return `<div class="fighter-plinth ${side} mystery" data-ms-lv="${st.stage}" style="${vars(st)}"><div class="plinth-light"></div><span class="side-tag">${side === 'player' ? 'P1 / YOUR FIGHTER' : 'CPU / YOUR RIVAL'}</span><div class="ms-art">${art}</div><div class="ms-card"><div class="ms-head"><span>SLOT MISTERI ${st.n} / ${st.total}</span><span class="ms-dots" role="img" aria-label="Petunjuk terbuka ${st.stage} dari ${st.max}">${dots}</span></div><h2 class="ms-title">???</h2><ol class="ms-clues">${clues}</ol><div class="ms-next">${next}</div></div></div>`;
} catch (e) { console.warn('[mystery]', e); return ''; }
}
// Dipanggil menu.js tiap selesai render layar pilih karakter.
function decorate(root, screen, hover, locked) {
if (screen !== 'select' || !enabled()) return;
try {
const list = states(locked), seen = seenMap();
root.querySelectorAll('.roster-tile.locked[data-fighter^="locked-"]').forEach(tile => {
const st = list[slotIndex(tile.dataset.fighter)];
if (st) decorateTile(tile, st, PREVIEW === null && st.stage > (seen[st.id]?.s || 0));
});
announce(list); evaluate(list); preview(root, hover, locked);
} catch (e) { console.warn('[mystery]', e); }
}
// Dipanggil menu.js saat sorotan berpindah: teks #fighter-moves dan #roster-notice; slot yang disorot dianggap sudah dilihat.
function preview(root, hover, locked) {
if (!enabled()) return;
try {
const st = stateOf(hover, locked), moves = root.querySelector('#fighter-moves'), note = root.querySelector('#roster-notice');
if (moves) {
moves.classList.toggle('ms-moves', !!st);
if (st) moves.innerHTML = `<span>Slot misteri ${st.n} / ${st.total} · petunjuk ${st.stage} / ${st.max}</span><b>${esc(st.next ? st.next.brief : 'Semua petunjuk terbuka')}</b>`;
}
if (st) { see(st); root.querySelector(`[data-fighter="${hover}"] .ms-new`)?.remove(); }
if (note) note.textContent = st ? `Slot misteri ${st.n}/${st.total} · ${st.next ? st.next.brief : 'petunjuk lengkap'}` : overview(locked);
} catch (e) { console.warn('[mystery]', e); }
}
// Pesan saat slot terkunci ditekan/dikonfirmasi; '' bila bukan slot misteri (menu.js memakai pesan lamanya).
function notice(id, locked) {
try { const st = stateOf(id, locked); return st ? `Slot misteri ${st.n}/${st.total} · ${st.next ? st.next.brief : 'petunjuk lengkap'}` : ''; } catch (_) { return ''; }
}
window.Mystery = {
panel, decorate, preview, notice,
overview: locked => { try { return enabled() ? overview(locked) : ''; } catch (_) { return ''; } },
// Ringkasan untuk uji manual di konsol: Mystery.states()
states: locked => { try { return states(locked).filter(Boolean).map(st => ({ slot: st.n, id: st.id, stage: st.stage, max: st.max, next: st.next && st.next.brief })); } catch (_) { return []; } }
};
})();