365 lines
32 KiB
JavaScript
365 lines
32 KiB
JavaScript
/* Meta layer: coins, shop, character locker (skin + shop move), achievements and easter eggs.
|
|
Everything is stored in this browser only (localStorage). The game engine reads it through window.Meta. */
|
|
(() => {
|
|
'use strict';
|
|
const KEY = 'konoha.save.v1';
|
|
const SKINS = {
|
|
standar: { name: 'STANDAR', price: 0, filter: '' },
|
|
senja: { name: 'SENJA', price: 150, filter: 'hue-rotate(-35deg) saturate(1.25)' },
|
|
rimba: { name: 'HIJAU RIMBA', price: 150, filter: 'hue-rotate(75deg) saturate(1.2)' },
|
|
laut: { name: 'BIRU LAUT', price: 150, filter: 'hue-rotate(160deg) saturate(1.2)' },
|
|
neon: { name: 'NEON', price: 300, filter: 'hue-rotate(270deg) saturate(1.9) brightness(1.08)' },
|
|
emas: { name: 'EMAS', price: 450, filter: 'sepia(1) saturate(3.2) hue-rotate(-12deg) brightness(1.1)' },
|
|
mono: { name: 'MONOKROM', price: 250, filter: 'grayscale(1) contrast(1.25)' }
|
|
};
|
|
const MOVES = {
|
|
seruduk: { name: 'SERUDUK KILAT', price: 350, cd: 7, desc: 'Menerjang maju dan menghantam lawan di depan. Kuat untuk membuka jarak.' },
|
|
hentak: { name: 'HENTAKAN BUMI', price: 500, cd: 10, desc: 'Menghentak tanah ke dua sisi; lawan di dekatmu terjatuh.' },
|
|
perisai: { name: 'PERISAI', price: 450, cd: 16, desc: 'Kebal serangan selama 1,6 detik. Pakai untuk lolos dari ultimate.' },
|
|
pulih: { name: 'AURA PEMULIH', price: 600, cd: 30, desc: 'Memulihkan 25 HP seketika.' },
|
|
isiulang: { name: 'ISI ULANG', price: 700, cd: 35, desc: 'Menyegarkan cooldown skill 1 dan 2 seketika.' }
|
|
};
|
|
const COMMANDS = [
|
|
['↓ ↓ + SPASI', 'UPPERCUT', 'Meluncurkan lawan ke atas dan menjatuhkannya.'],
|
|
['↓ → + SPASI', 'TINJU MAJU', 'Maju sambil memukul, jangkauan panjang.'],
|
|
['SPASI x3', 'KOMBO', 'Rantai tiga pukulan; pukulan ketiga paling kuat.'],
|
|
['W W', 'LOMPAT GANDA', 'Lompat kedua di udara (berputar).'],
|
|
['K', 'JURUS TOKO', 'Memakai jurus toko yang dipasang di menu Karakter.'],
|
|
['TAHAN L', 'BLOK', 'Mengurangi damage 80%. Terlalu lama diserang = guard pecah.'],
|
|
['KETUK L', 'PARRY', 'Ketuk tepat sebelum kena: tanpa damage dan lawan terhenti.'],
|
|
['J', 'LEMPAR', 'Menembus blok. Harus dekat dan di tanah.']
|
|
];
|
|
const ALT_PRICE = 450;
|
|
function altOptions(id) {
|
|
const f = window.FIGHTERS?.[id]; if (!f) return [];
|
|
return ['skill1', 'skill2'].flatMap((slot, i) => {
|
|
const kind = f.skills?.[slot]?.kind, proj = ['projectile', 'wave', 'homing'].includes(kind), nm = f.names[i + 1];
|
|
return [{ key: `${id}:${slot}:ganda`, slot, v: 'ganda', name: nm + (proj ? ' GANDA' : ' CEPAT'), desc: proj ? 'Menembak dua kali lipat dengan damage lebih kecil per peluru.' : 'Lebih cepat dan cooldown pendek, damage lebih kecil.' },
|
|
{ key: `${id}:${slot}:berat`, slot, v: 'berat', name: nm + ' BERAT', desc: 'Lebih besar dan keras, tetapi lebih lambat dan cooldown panjang.' }];
|
|
});
|
|
}
|
|
const ACH = {
|
|
first_win: ['PERTAMA KALI', 'Menangkan satu pertandingan.'],
|
|
perfect: ['BERSIH', 'Menang tanpa kehilangan satu ronde pun.'],
|
|
combo5: ['KOMBO 5', 'Capai kombo 5 pukulan.'],
|
|
combo10: ['KOMBO 10', 'Capai kombo 10 pukulan.'],
|
|
hard_win: ['SANG PEMBURU', 'Menang di tingkat selain Easy.'],
|
|
wins10: ['SEPULUH KALI', 'Menangkan 10 pertandingan.'],
|
|
shopper: ['PEMBELI', 'Beli barang pertamamu di toko.'],
|
|
fashion: ['MODIS', 'Miliki 3 skin.'],
|
|
rich: ['KAYA', 'Kumpulkan total 2000 koin.'],
|
|
konami: ['KODE LAMA', 'Temukan kode rahasia lama.'],
|
|
syududu: ['SYUDUDU', 'Panggil semut.'],
|
|
mbg: ['TRUK LEWAT', 'Panggil truk katering.'],
|
|
esemka: ['KONVOI', 'Panggil konvoi.'],
|
|
penasaran: ['PENASARAN', 'Ketuk slot tanda tanya berulang kali.'],
|
|
penasaran_berat: ['PENASARAN BERAT', 'Buka semua petunjuk slot misteri.'],
|
|
logo: ['TELITI', 'Temukan rahasia di judul.'],
|
|
parry: ['TEPAT WAKTU', 'Lakukan parry yang berhasil.'],
|
|
arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'],
|
|
bayangan: ['BAYANGAN', 'Hadapi sosok yang menunggu di ujung Arcade. Gelar: PENAKLUK BAYANGAN.'],
|
|
survival5: ['BERTAHAN', 'Menang 5 kali berturut-turut di Survival.'],
|
|
twoplayer: ['TEMAN BERMAIN', 'Mainkan satu pertandingan dua pemain.'],
|
|
special: ['SPESIALIS', 'Beli satu varian skill karakter.'],
|
|
reuni: ['REUNI', 'Temukan semua dialog rahasia.']
|
|
};
|
|
const blank = () => ({ v: 1, coins: 0, earned: 0, voice: true, owned: { skin: ['standar'], move: [], alt: [] }, skin: {}, style: {}, move: null, alt: {}, best: { survival: 0 }, stats: { wins: 0, losses: 0, bestCombo: 0, perfects: 0 }, ach: {}, eggs: {} });
|
|
let save = blank();
|
|
try { const raw = JSON.parse(localStorage.getItem(KEY) || 'null'); if (raw && raw.v === 1) save = { ...blank(), ...raw, owned: { ...blank().owned, ...raw.owned }, stats: { ...blank().stats, ...raw.stats } }; } catch (_) {}
|
|
const persist = () => { try { localStorage.setItem(KEY, JSON.stringify(save)); } catch (_) {} };
|
|
// The styles are attached here too, so the overlay is never unstyled (and hidden under the menu) when index.html is stale.
|
|
if (!document.querySelector('link[href^="meta.css"]')) { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = 'meta.css?' + Date.now(); document.head.append(l); }
|
|
const $ = s => document.querySelector(s);
|
|
const esc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
|
|
// ---- toasts ----
|
|
let toastBox, held = [];
|
|
// Selama layar VS atau kartu cerita (story.js) tampil toast ditahan (menutupi bilah atas VS / teks cerita), lalu dimunculkan penuh setelah layar selesai atau dibatalkan.
|
|
const busy = () => window.VS?.active || window.Story?.active;
|
|
function holdToasts() { setTimeout(() => { if (busy()) return holdToasts(); const q = held; held = []; for (const [m, k] of q) toast(m, k); }, 150); }
|
|
function toast(msg, kind = '') {
|
|
if (busy()) { held.push([msg, kind]); if (held.length === 1) holdToasts(); return; }
|
|
if (!toastBox) { toastBox = document.createElement('div'); toastBox.id = 'meta-toasts'; document.body.append(toastBox); }
|
|
const el = document.createElement('div'); el.className = 'meta-toast ' + kind; el.textContent = msg; toastBox.append(el);
|
|
setTimeout(() => el.classList.add('out'), 3200); setTimeout(() => el.remove(), 3800);
|
|
}
|
|
function addCoins(n) { save.coins += n; if (n > 0) save.earned += n; persist(); checkRich(); refreshCoins(); }
|
|
function checkRich() { if (save.earned >= 2000) unlock('rich'); }
|
|
function unlock(id) {
|
|
if (save.ach[id] || !ACH[id]) return false;
|
|
save.ach[id] = Date.now(); save.coins += 50; save.earned += 50; persist(); toast('PENCAPAIAN: ' + ACH[id][0] + ' +50 koin', 'ach'); refreshCoins(); return true;
|
|
}
|
|
|
|
// ---- engine API ----
|
|
function skinFilter(id) { const s = SKINS[save.skin[id] || 'standar']; return s && save.owned.skin.includes(save.skin[id] || 'standar') ? s.filter : ''; }
|
|
function altSkill(id, slot) { const v = save.alt?.[id]?.[slot]; return v && save.owned.alt.includes(`${id}:${slot}:${v}`) ? v : null; }
|
|
// Shouts use the browser's own text-to-speech (generic Indonesian voice), never an imitation of anyone's voice.
|
|
let lastShout = 0;
|
|
function shout(text) {
|
|
if (!save.voice || !text || !window.speechSynthesis || Date.now() - lastShout < 900) return; lastShout = Date.now();
|
|
try { speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(String(text).toLowerCase()); u.lang = 'id-ID'; u.rate = 1.15; u.pitch = 1.25; u.volume = .9; speechSynthesis.speak(u); } catch (_) {}
|
|
}
|
|
function event(name) { if (name === 'parry') unlock('parry'); if (name === 'twoplayer') unlock('twoplayer'); }
|
|
// Gaya bertarung per karakter (RENCANA 3.3): agresif | seimbang | bertahan. Simpanan lama tanpa kunci style atau dengan nilai asing dibaca sebagai Seimbang.
|
|
function styleOf(id) { const k = save.style && typeof save.style === 'object' ? save.style[id] : null; return window.Passives ? window.Passives.styleKey(k) : 'seimbang'; }
|
|
function setStyle(id, k) { if (!window.Passives || !window.STYLES?.[k]) return false; if (!save.style || typeof save.style !== 'object') save.style = {}; save.style[id] = k; persist(); return true; }
|
|
function equippedMove() { return save.move && save.owned.move.includes(save.move) ? save.move : null; }
|
|
let chip;
|
|
function hud(info) {
|
|
if (!chip) { chip = document.createElement('div'); chip.id = 'shop-chip'; document.body.append(chip); }
|
|
const m = equippedMove() ? info.move : null;
|
|
const key = m ? m.name + '|' + Math.ceil(info.cd) : '';
|
|
if (chip.dataset.k === key) return; chip.dataset.k = key;
|
|
chip.hidden = !m; chip.innerHTML = m ? `<kbd>K</kbd><span>${esc(m.name)}</span><b>${info.cd > 0 ? Math.ceil(info.cd) + 's' : 'SIAP'}</b>` : '';
|
|
chip.classList.toggle('cool', info.cd > 0);
|
|
}
|
|
// Rincian koin per baris disimpan di lastAward (dibaca layar hasil); jumlah koin tidak berubah.
|
|
let lastAward = null, lastMatch = null;
|
|
function award(r) {
|
|
const won = r.winner === 'player', parts = [], lines = [];
|
|
const add = (key, label, amount, partLabel, note) => { lines.push({ key, label, amount, ...(note ? { note } : {}) }); parts.push(partLabel + ' +' + amount); };
|
|
let gain = won ? 120 : 30; add(won ? 'win' : 'play', won ? 'Menang' : 'Hadiah main', gain, won ? 'Menang' : 'Hadiah main');
|
|
if (won) {
|
|
if (r.level && r.level !== 'easy') { const b = { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; gain += b; add('level', 'Tingkat kesulitan', b, 'Tingkat', ({ medium: 'Sedang', hard: 'Sulit', excellent: 'Sangat sulit' })[r.level]); }
|
|
if (r.perfect) { gain += 60; add('perfect', 'Bersih', 60, 'Bersih', 'Tanpa kehilangan ronde'); }
|
|
}
|
|
const cb = Math.min(60, (r.bestCombo || 0) * 4); if (cb) { gain += cb; add('combo', 'Bonus kombo', cb, 'Kombo', r.bestCombo + ' pukulan, ronde terakhir'); }
|
|
if (won) { save.stats.wins++; unlock('first_win'); if (save.stats.wins >= 10) unlock('wins10'); if (r.perfect) { save.stats.perfects++; unlock('perfect'); } if (r.level && r.level !== 'easy') unlock('hard_win'); } else save.stats.losses++;
|
|
save.stats.bestCombo = Math.max(save.stats.bestCombo, r.bestCombo || 0);
|
|
window.MatchStats?.commit(save.stats, r.stats); // statistik seumur hidup (kunci baru di save.stats; simpanan lama tanpa kunci dibaca 0), disimpan oleh addCoins di bawah
|
|
if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10');
|
|
const prev = run ? run.earned || 0 : 0; if (run) run.earned = prev + gain;
|
|
lastAward = { won, gain, lines, level: r.level || null, prev }; lastMatch = { ...r };
|
|
addCoins(gain);
|
|
const handled = run ? advanceRun(won) : false;
|
|
// Layar hasil (pertandingan biasa atau akhir Arcade/Survival) sudah memuat rincian yang sama; toast hanya untuk lawan di tengah rangkaian.
|
|
if (run || !(window.ResultView && window.FrontEnd?.showResult)) toast('KOIN +' + gain + ' (' + parts.join(', ') + ')', 'coin');
|
|
return handled;
|
|
}
|
|
// ---- Arcade (fight everyone once, one life) and Survival (endless, harder each win) ----
|
|
// Arcade memakai kartu cerita (story.js, RENCANA 3.5): pembuka, antar-lawan, bos, dan akhir. Setelah semua lawan kalah menunggu bos rahasia BAYANGAN
|
|
// (RENCANA 3.6): salinan gelap karakter pemain, tingkat excellent, HP +40% dan damage +10% (game.js startMatch {shadow: true}). Hadiah tamat dibayar begitu
|
|
// lawan terakhir kalah (seperti dulu); menang melawan Bayangan memberi +1000 koin, pencapaian BAYANGAN, dan gelar; kalah tetap dianggap tamat tanpa bonus bos.
|
|
// run.boss: null | 'pending' (kartu bos) | 'fight' (pertandingan Bayangan). Survival tidak memakai cerita.
|
|
let run = null;
|
|
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
|
const ARCADE_COINS = 500, BOSS_COINS = 1000;
|
|
const stageOrder = () => shuffle(Object.keys(window.MatchRules?.stages || { bellora: 1 })).concat('bellora');
|
|
// ?arcade=N (uji manual) memendekkan Arcade menjadi N lawan.
|
|
const arcadeLimit = () => { try { return Math.max(0, parseInt(new URLSearchParams(location.search).get('arcade'), 10) || 0); } catch (_) { return 0; } };
|
|
// Kartu cerita lalu langkah berikutnya. Tanpa modul/teks (atau bila kartu gagal) langsung lanjut; next dipanggil tepat satu kali.
|
|
function story(card, next) {
|
|
let shown = false;
|
|
try { shown = !!window.Story?.show?.(card, next); } catch (e) { console.error(e); }
|
|
if (!shown) next();
|
|
}
|
|
function startRun(kind, player) {
|
|
let queue = kind === 'arcade' ? shuffle(roster().filter(x => x !== player)) : [];
|
|
const lim = arcadeLimit(); if (kind === 'arcade' && lim > 0) queue = queue.slice(0, lim);
|
|
const r = run = { kind, player, wins: 0, queue, boss: null, bonus: 0 };
|
|
if (kind === 'arcade') story({ kind: 'intro', player, total: queue.length }, () => { if (run === r) nextFight(); });
|
|
else nextFight();
|
|
}
|
|
function nextFight(order) {
|
|
const g = window.__game;
|
|
const enemy = run.kind === 'arcade' ? run.queue[run.wins] : shuffle(roster().filter(x => x !== run.player))[0];
|
|
const level = run.kind === 'arcade' ? 'medium' : run.wins < 3 ? 'easy' : run.wins < 6 ? 'medium' : run.wins < 9 ? 'hard' : 'excellent';
|
|
let ok = false;
|
|
for (const st of order || stageOrder()) { ok = g.startMatch({ player: run.player, enemy, stage: st, level, mode: 'versus', vs: { label: (run.kind === 'arcade' ? 'ARCADE · LAWAN ' + (run.wins + 1) + ' / ' + run.queue.length : 'SURVIVAL · LAWAN ' + (run.wins + 1)) } }); if (ok) break; }
|
|
if (!ok) { toast('Aset belum siap, coba lagi.'); run = null; window.FrontEnd?.home(); }
|
|
}
|
|
// Setelah lawan kalah: Arcade menampilkan kartu antar-lawan (panggung lawan berikutnya sama dengan latar kartunya), Survival langsung lanjut.
|
|
function betweenFights() {
|
|
const r = run; if (!r) return;
|
|
if (r.kind !== 'arcade') return nextFight();
|
|
const order = stageOrder();
|
|
story({ kind: 'between', player: r.player, enemy: r.queue[r.wins], step: r.wins + 1, total: r.queue.length, stage: order[0] }, () => { if (run === r) nextFight(order); });
|
|
}
|
|
// Lawan terakhir kalah: hadiah tamat langsung dibayar, lalu kartu bos dan pertandingan Bayangan di Moonrise Bastion (malam bulan purnama).
|
|
function arcadeCleared(r) {
|
|
r.bonus = ARCADE_COINS; addCoins(r.bonus); unlock('arcade_clear'); r.boss = 'pending';
|
|
setTimeout(() => { if (run === r) story({ kind: 'boss', player: r.player }, () => { if (run === r) startBoss(); }); }, 2600);
|
|
return true;
|
|
}
|
|
function startBoss() {
|
|
const r = run, g = window.__game; r.boss = 'fight';
|
|
let ok = false;
|
|
for (const st of ['moonrise'].concat(stageOrder())) { ok = g.startMatch({ player: r.player, enemy: r.player, stage: st, level: 'excellent', mode: 'versus', shadow: true, vs: { label: 'ARCADE · BOS RAHASIA' } }); if (ok) break; }
|
|
if (!ok) { r.boss = null; finishRun(true); } // aset belum siap: Arcade tetap tamat, tanpa bos
|
|
}
|
|
function advanceRun(won) {
|
|
const r = run;
|
|
if (r.boss === 'fight') { finishRun(true, won ? 'win' : 'lose'); return true; }
|
|
if (!won) { finishRun(false); return true; }
|
|
r.wins++;
|
|
if (r.kind === 'arcade' && r.wins >= r.queue.length) return arcadeCleared(r);
|
|
if (r.kind === 'survival') { save.best.survival = Math.max(save.best.survival, r.wins); if (r.wins >= 5) unlock('survival5'); persist(); }
|
|
setTimeout(() => { if (run === r) betweenFights(); }, 2600); return true;
|
|
}
|
|
// boss: 'win' (Bayangan kalah) | 'lose' (kalah dari Bayangan, Arcade tetap tamat) | null (tanpa bos).
|
|
function finishRun(cleared, boss = null) {
|
|
const r = run; run = null; let bonus = 0;
|
|
if (r.kind === 'arcade' && cleared) { if (!r.bonus) { r.bonus = ARCADE_COINS; addCoins(r.bonus); unlock('arcade_clear'); } bonus = r.bonus; } // normalnya sudah dibayar di arcadeCleared
|
|
else if (r.kind === 'survival') { bonus = r.wins * 30; if (bonus) addCoins(bonus); }
|
|
const bossBonus = boss === 'win' ? BOSS_COINS : 0, total = bonus + bossBonus, fresh = boss === 'win' && !save.ach.bayangan;
|
|
const result = () => {
|
|
// Layar akhir sama gayanya dengan layar hasil pertandingan (menu.js 'result' lewat ResultView); layar lama tetap jadi cadangan.
|
|
if (lastMatch && window.ResultView && window.FrontEnd?.showResult) {
|
|
// Satu baris per kelompok supaya muat: lawan sebelumnya, pertandingan terakhir, bonus rangkaian (bonus tamat dan Bayangan digabung).
|
|
const base = lastAward || { gain: 0, lines: [], prev: 0 }, before = cleared && !boss ? r.wins - 1 : r.wins, lines = [];
|
|
if (base.prev > 0) lines.push({ key: 'prev', label: 'Lawan sebelumnya', amount: base.prev, note: before + ' pertandingan' });
|
|
lines.push({ key: 'last', label: boss ? 'Melawan Bayangan' : 'Pertandingan terakhir', amount: base.gain, note: base.won ? 'Menang' : 'Kalah' });
|
|
if (total) lines.push({ key: 'run', label: bossBonus ? 'Bonus tamat + Bayangan' : r.kind === 'arcade' ? 'Bonus Arcade tamat' : 'Bonus rangkaian menang', amount: total, note: bossBonus ? bonus + ' + ' + bossBonus : r.kind === 'arcade' ? '' : r.wins + ' x 30' });
|
|
window.FrontEnd.showResult({ ...lastMatch, mode: 'versus', award: { ...base, lines, gain: base.prev + base.gain + total }, run: { kind: r.kind, wins: r.wins, total: r.queue.length, cleared, best: save.best.survival, bonus: total, boss } });
|
|
return;
|
|
}
|
|
if (!root) build(); root.hidden = false;
|
|
const title = r.kind === 'arcade' ? (cleared ? 'ARCADE TAMAT!' : 'ARCADE BERAKHIR') : 'SURVIVAL BERAKHIR';
|
|
const sub = r.kind === 'arcade' ? (cleared ? (boss === 'win' ? 'Bayangan dikalahkan.' : 'Semua lawan dikalahkan.') : 'Kalah di lawan ke-' + (r.wins + 1) + ' dari ' + r.queue.length + '.') : 'Kemenangan beruntun: ' + r.wins + ' · rekor ' + save.best.survival;
|
|
root.innerHTML = `<div class="meta-panel run-end"><h2>${title}</h2><p>${sub}</p><p class="meta-coin">${total ? 'Bonus ◆ +' + total : ''}</p><p><button class="mt on" data-runmenu>KE MENU UTAMA</button></p></div>`;
|
|
};
|
|
// Arcade tamat: akhir cerita khas karakter dulu, baru layar hasil. Hadiah Bayangan (koin, pencapaian, gelar) diberikan sesudah kartu mulai agar toast-nya ditahan sampai kartu selesai.
|
|
if (r.kind === 'arcade' && cleared) story({ kind: 'ending', player: r.player, boss, fresh }, result); else result();
|
|
if (boss === 'win') {
|
|
addCoins(bossBonus); save.title = window.Story?.title || 'PENAKLUK BAYANGAN'; persist();
|
|
if (unlock('bayangan')) toast('GELAR BARU: ' + save.title, 'ach');
|
|
}
|
|
}
|
|
|
|
// ---- UI ----
|
|
let root, tab = 'shop', who = null, notice = '';
|
|
const roster = () => Object.keys(window.FIGHTERS || {}).concat(window.Browo && window.BROWO_MANIFEST ? ['browo'] : []);
|
|
const fname = id => window.FIGHTERS?.[id]?.name || (id === 'browo' ? 'BRO WO' : id.toUpperCase());
|
|
const fnames = id => window.FIGHTERS?.[id]?.names || window.Browo?.names || [];
|
|
function refreshCoins() { const c = $('#meta-coins'); if (c) c.textContent = save.coins; }
|
|
function build() {
|
|
root = document.createElement('div'); root.id = 'meta-overlay'; root.hidden = true; document.body.append(root);
|
|
root.addEventListener('click', onClick);
|
|
}
|
|
function open(t = 'shop') {
|
|
if (!root) build(); tab = t; if (!who) who = roster()[0]; root.hidden = false; render();
|
|
document.addEventListener('keydown', escClose, true);
|
|
}
|
|
function close() { root.hidden = true; document.removeEventListener('keydown', escClose, true); }
|
|
function escClose(e) { if (e.key === 'Escape') { e.stopPropagation(); close(); } }
|
|
function tabs() { return ['shop:TOKO', 'locker:KARAKTER', 'book:KOLEKSI'].map(x => { const [k, l] = x.split(':'); return `<button class="mt ${tab === k ? 'on' : ''}" data-tab="${k}">${l}</button>`; }).join(''); }
|
|
function render() {
|
|
let body = '';
|
|
if (tab === 'shop') body = renderShop(); else if (tab === 'locker') body = renderLocker(); else body = renderBook();
|
|
root.innerHTML = `<div class="meta-panel"><header><button class="meta-x" data-close>← KEMBALI</button><nav>${tabs()}</nav><span class="meta-coin">◆ <b id="meta-coins">${save.coins}</b></span></header>${notice ? `<p class="meta-note">${esc(notice)}</p>` : ''}<div class="meta-body">${body}</div></div>`;
|
|
}
|
|
function renderShop() {
|
|
const skins = Object.entries(SKINS).filter(([k]) => k !== 'standar').map(([k, s]) => {
|
|
const own = save.owned.skin.includes(k);
|
|
return `<article class="item"><div class="swatch" style="filter:${s.filter || 'none'}"><img src="${portrait(who || roster()[0])}" alt=""></div><h4>${s.name}</h4><p>Warna baru untuk semua karakter.</p><button data-buy="skin:${k}" ${own ? 'disabled' : ''}>${own ? 'DIMILIKI' : '◆ ' + s.price}</button></article>`;
|
|
}).join('');
|
|
const moves = Object.entries(MOVES).map(([k, m]) => {
|
|
const own = save.owned.move.includes(k);
|
|
return `<article class="item move"><h4>${m.name}</h4><p>${m.desc}</p><small>Cooldown ${m.cd} detik · tombol K</small><button data-buy="move:${k}" ${own ? 'disabled' : ''}>${own ? 'DIMILIKI' : '◆ ' + m.price}</button></article>`;
|
|
}).join('');
|
|
const alts = altOptions(who).map(o => { const own = save.owned.alt.includes(o.key); return `<article class="item move"><h4>${esc(o.name)}</h4><p>${o.desc}</p><small>${fname(who)} · ${o.slot === 'skill1' ? 'tombol I' : 'tombol O'}</small><button data-buy="alt:${o.key}" ${own ? 'disabled' : ''}>${own ? 'DIMILIKI' : '◆ ' + ALT_PRICE}</button></article>`; }).join('');
|
|
const whoPick = roster().map(id => `<button class="pill ${id === who ? 'on' : ''}" data-shopwho="${id}">${fname(id)}</button>`).join('');
|
|
return `<h3>Jurus toko</h3><div class="grid">${moves}</div><h3>Varian skill karakter</h3><div class="pills">${whoPick}</div><div class="grid" style="margin-top:10px">${alts || '<p class="meta-hint">Tidak ada varian untuk karakter ini.</p>'}</div><h3>Skin warna</h3><div class="grid">${skins}</div><p class="meta-hint">Koin didapat dari menang pertandingan, kombo, dan pencapaian. Pasang barang di menu KARAKTER.</p>`;
|
|
}
|
|
const portrait = id => `assets/${id}/ui/portrait.webp`;
|
|
function renderLocker() {
|
|
const ids = roster(), sk = save.skin[who] || 'standar';
|
|
const pick = ids.map(id => `<button class="chip ${id === who ? 'on' : ''}" data-who="${id}"><img src="${portrait(id)}" alt=""><span>${fname(id)}</span></button>`).join('');
|
|
const skins = save.owned.skin.map(k => `<button class="pill ${k === sk ? 'on' : ''}" data-skin="${k}">${SKINS[k].name}</button>`).join('');
|
|
const moves = [`<button class="pill ${!equippedMove() ? 'on' : ''}" data-move="">KOSONG</button>`].concat(save.owned.move.map(k => `<button class="pill ${save.move === k ? 'on' : ''}" data-move="${k}">${MOVES[k].name}</button>`)).join('');
|
|
const altRows = ['skill1', 'skill2'].map(slot => { const opts = altOptions(who).filter(o => o.slot === slot && save.owned.alt.includes(o.key)); const cur = save.alt?.[who]?.[slot] || ''; return `<h5>Skill ${slot === 'skill1' ? '1 (I)' : '2 (O)'}</h5><div class="pills"><button class="pill ${!cur ? 'on' : ''}" data-alt="${slot}:">ASLI</button>${opts.map(o => `<button class="pill ${cur === o.v ? 'on' : ''}" data-alt="${slot}:${o.v}">${esc(o.name)}</button>`).join('')}</div>`; }).join('');
|
|
const n = fnames(who), pas = window.Passives?.lockerHtml(who, styleOf(who)) || '';
|
|
return `<div class="roster">${pick}</div><div class="locker"><div class="preview" style="filter:${SKINS[sk]?.filter || 'none'}"><img src="${portrait(who)}" alt=""></div><div class="info"><h3>${fname(who)}</h3>${pas}<h5>Skin</h5><div class="pills">${skins}</div><h5>Jurus toko (tombol K)</h5><div class="pills">${moves}</div><p class="meta-hint">${save.owned.move.length ? '' : 'Belum punya jurus toko. Beli di tab TOKO.'}</p>${altRows}<h5>Daftar gerakan</h5><ul class="moves">${n.length ? `<li><b>SPASI</b> ${esc(n[0] || '')}</li><li><b>I</b> ${esc(n[1] || '')}</li><li><b>O</b> ${esc(n[2] || '')}</li><li><b>P</b> ${esc(n[3] || '')}</li>` : ''}${COMMANDS.map(c => `<li><b>${c[0]}</b> ${c[1]} — ${c[2]}</li>`).join('')}</ul></div></div>`;
|
|
}
|
|
function renderBook() {
|
|
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
|
|
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'penasaran_berat', 'logo', 'reuni'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
|
|
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p>${window.MatchStats?.lifetimeHtml(st) || ''}<h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
|
}
|
|
function onClick(e) {
|
|
const b = e.target.closest('button'); if (!b) return;
|
|
if (b.hasAttribute('data-close')) return close();
|
|
if (b.dataset.tab) { tab = b.dataset.tab; notice = ''; return render(); }
|
|
if (b.dataset.who || b.dataset.shopwho) { who = b.dataset.who || b.dataset.shopwho; return render(); }
|
|
if (b.hasAttribute('data-runmenu')) { close(); return window.FrontEnd?.home(); }
|
|
if (b.dataset.alt !== undefined && b.hasAttribute('data-alt')) { const [slot, v] = b.dataset.alt.split(':'); (save.alt[who] = save.alt[who] || {})[slot] = v || null; persist(); return render(); }
|
|
if (b.dataset.style) { setStyle(who, b.dataset.style); return render(); }
|
|
if (b.dataset.skin) { save.skin[who] = b.dataset.skin; persist(); return render(); }
|
|
if (b.dataset.move !== undefined && b.hasAttribute('data-move')) { save.move = b.dataset.move || null; persist(); return render(); }
|
|
if (b.dataset.buy) {
|
|
const kind = b.dataset.buy.split(':')[0], id = b.dataset.buy.slice(kind.length + 1), item = kind === 'alt' ? { name: altOptions(who).find(o => o.key === id)?.name || id, price: ALT_PRICE } : (kind === 'skin' ? SKINS : MOVES)[id];
|
|
if (save.owned[kind].includes(id)) return;
|
|
if (save.coins < item.price) { notice = 'Koin belum cukup untuk ' + item.name + '.'; return render(); }
|
|
save.coins -= item.price; save.owned[kind].push(id); if (kind === 'move' && !save.move) save.move = id;
|
|
if (kind === 'alt') { const [c, sl, v] = id.split(':'); (save.alt[c] = save.alt[c] || {})[sl] = v; unlock('special'); }
|
|
notice = item.name + ' dibeli.'; persist(); unlock('shopper'); if (save.owned.skin.length >= 4) unlock('fashion'); return render();
|
|
}
|
|
if (b.hasAttribute('data-voice')) { save.voice = !save.voice; persist(); return render(); }
|
|
if (b.hasAttribute('data-export')) { const code = btoa(unescape(encodeURIComponent(JSON.stringify(save)))); navigator.clipboard?.writeText(code).then(() => { notice = 'Kode disalin ke papan klip.'; render(); }, () => { notice = 'Tidak bisa menyalin.'; render(); }); return; }
|
|
if (b.hasAttribute('data-import')) { const code = prompt('Tempel kode simpanan:'); if (!code) return; try { const d = JSON.parse(decodeURIComponent(escape(atob(code.trim())))); if (d.v !== 1) throw 0; save = { ...blank(), ...d }; persist(); notice = 'Simpanan dimuat.'; } catch (_) { notice = 'Kode tidak valid.'; } return render(); }
|
|
if (b.hasAttribute('data-reset')) { if (confirm('Hapus semua koin, skin, jurus, dan pencapaian?')) { save = blank(); persist(); notice = 'Simpanan dihapus.'; render(); } }
|
|
}
|
|
|
|
// ---- easter eggs ----
|
|
const KONAMI = ['arrowup', 'arrowup', 'arrowdown', 'arrowdown', 'arrowleft', 'arrowright', 'arrowleft', 'arrowright', 'b', 'a'];
|
|
let kbuf = [], tbuf = '';
|
|
function swarm(emoji, n, cls) {
|
|
const box = document.createElement('div'); box.className = 'egg-layer ' + cls; document.body.append(box);
|
|
for (let i = 0; i < n; i++) { const s = document.createElement('span'); s.textContent = emoji; s.style.setProperty('--y', (8 + Math.random() * 84) + 'vh'); s.style.setProperty('--d', (Math.random() * 1.6) + 's'); s.style.setProperty('--t', (3 + Math.random() * 2) + 's'); s.style.setProperty('--s', (22 + Math.random() * 30) + 'px'); box.append(s); }
|
|
setTimeout(() => box.remove(), 6500);
|
|
}
|
|
function egg(id, label, fn) {
|
|
fn(); if (!save.eggs[id]) { save.eggs[id] = 1; addCoins(100); toast(label + ' +100 koin', 'ach'); } if (ACH[id]) unlock(id);
|
|
}
|
|
// Easter egg berpasangan (RENCANA 3.4): layar VS memanggil pairEgg(kunci) saat dialog rahasia pasangan itu tampil (daftar pasangan: DIALOGS.rahasia di content/dialogs.js).
|
|
// Koin dan toast hanya sekali per pasangan (save.eggs['dialog:<kunci>']); pencapaian REUNI terbuka setelah semuanya ditemukan. Mengembalikan true bila baru ditemukan.
|
|
const PAIR_COINS = 75, pairKeys = () => Object.keys(window.DIALOGS?.rahasia || {});
|
|
function pairEgg(key) {
|
|
const k = 'dialog:' + key; if (!pairKeys().includes(key) || save.eggs[k]) return false;
|
|
save.eggs[k] = 1; addCoins(PAIR_COINS); toast('DIALOG RAHASIA TERBUKA +' + PAIR_COINS + ' koin', 'ach');
|
|
if (pairKeys().every(x => save.eggs['dialog:' + x])) unlock('reuni');
|
|
return true;
|
|
}
|
|
const CODES = {
|
|
syududu: () => egg('syududu', 'SYUDUDU!', () => swarm('🐜', 40, 'ants')),
|
|
mbg: () => egg('mbg', 'TRUK KATERING LEWAT', () => swarm('🚚', 1, 'truck')),
|
|
esemka: () => egg('esemka', 'KONVOI ESEMKA', () => swarm('🚗', 6, 'truck'))
|
|
};
|
|
document.addEventListener('keydown', e => {
|
|
if (e.target?.matches?.('input,textarea')) return;
|
|
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
|
kbuf.push(k); if (kbuf.length > KONAMI.length) kbuf.shift();
|
|
if (kbuf.join() === KONAMI.join()) { kbuf = []; egg('konami', 'KODE RAHASIA TERBUKA', () => swarm('✦', 24, 'ants')); }
|
|
if (k.length === 1) { tbuf = (tbuf + k).slice(-10); for (const c in CODES) if (tbuf.endsWith(c)) { tbuf = ''; CODES[c](); } }
|
|
});
|
|
let logoClicks = 0, lockClicks = 0, lt = 0;
|
|
document.addEventListener('click', e => {
|
|
if (e.target.closest?.('.home-title h1')) { if (++logoClicks >= 7) { logoClicks = 0; egg('logo', 'KAMU TELITI', () => swarm('✦', 20, 'ants')); } }
|
|
const lock = e.target.closest?.('[data-fighter^="locked"]');
|
|
if (lock) { const now = Date.now(); if (now - lt > 4000) lockClicks = 0; lt = now; lockClicks++; if (lockClicks === 3) toast('Siapa yang akan datang berikutnya…?'); if (lockClicks >= 6) { lockClicks = 0; egg('penasaran', 'RASA INGIN TAHUMU DIHARGAI', () => {}); } }
|
|
}, true);
|
|
|
|
// ---- cheat / redeem codes (Settings) ----
|
|
const REDEEM = { rizkyganteng: { coins: 10000, label: 'KODE DITERIMA: +10.000 koin' } };
|
|
function redeem(raw) {
|
|
const c = String(raw || '').trim().toLowerCase(), r = REDEEM[c];
|
|
if (!c) return 'Ketik kodenya dulu.';
|
|
if (!r) return 'Kode tidak dikenal.';
|
|
if (save.eggs['code:' + c]) return 'Kode ini sudah dipakai.';
|
|
save.eggs['code:' + c] = 1; addCoins(r.coins); toast(r.label, 'coin'); return r.label;
|
|
}
|
|
function mountCheatRow() {
|
|
const panel = document.querySelector('#settings-panel'); if (!panel || panel.querySelector('#cheat-row')) return;
|
|
const row = document.createElement('div'); row.className = 'setting-row'; row.id = 'cheat-row';
|
|
row.innerHTML = '<span>Kode cheat<small id="cheat-msg">Masukkan kode untuk hadiah koin</small></span><span class="cheat-box"><input id="cheat-input" type="text" autocomplete="off" spellcheck="false" placeholder="kode"><button type="button" id="cheat-go">PAKAI</button></span>';
|
|
const anchor = panel.querySelector('.dialog-buttons'); (anchor || panel.lastElementChild).before(row);
|
|
const go = () => { const input = row.querySelector('#cheat-input'); row.querySelector('#cheat-msg').textContent = redeem(input.value); input.value = ''; };
|
|
row.querySelector('#cheat-go').onclick = go;
|
|
row.querySelector('#cheat-input').addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
|
row.querySelector('#cheat-input').addEventListener('keyup', e => e.stopPropagation());
|
|
}
|
|
mountCheatRow();
|
|
|
|
window.Meta = { get lastAward() { return lastAward; }, styleOf, setStyle, redeem, pairEgg, shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } };
|
|
})();
|