180 lines
14 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 []; } }
|
|
};
|
|
})();
|