191 lines
15 KiB
JavaScript
191 lines
15 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.']
|
|
];
|
|
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.'],
|
|
logo: ['TELITI', 'Temukan rahasia di judul.']
|
|
};
|
|
const blank = () => ({ v: 1, coins: 0, earned: 0, owned: { skin: ['standar'], move: [] }, skin: {}, move: null, 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 (_) {} };
|
|
const $ = s => document.querySelector(s);
|
|
const esc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
|
|
// ---- toasts ----
|
|
let toastBox;
|
|
function toast(msg, kind = '') {
|
|
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 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);
|
|
}
|
|
function award(r) {
|
|
const won = r.winner === 'player', parts = [];
|
|
let gain = won ? 120 : 30; parts.push(won ? 'Menang +120' : 'Hadiah main +30');
|
|
if (won) {
|
|
if (r.level && r.level !== 'easy') { const b = { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; gain += b; parts.push('Tingkat +' + b); }
|
|
if (r.perfect) { gain += 60; parts.push('Bersih +60'); }
|
|
}
|
|
const cb = Math.min(60, (r.bestCombo || 0) * 4); if (cb) { gain += cb; parts.push('Kombo +' + cb); }
|
|
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);
|
|
if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10');
|
|
addCoins(gain); toast('KOIN +' + gain + ' (' + parts.join(', ') + ')', 'coin');
|
|
}
|
|
|
|
// ---- 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('');
|
|
return `<h3>Jurus toko</h3><div class="grid">${moves}</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 n = fnames(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><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><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', 'logo'].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><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><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) { who = b.dataset.who; 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, id] = b.dataset.buy.split(':'), item = (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;
|
|
notice = item.name + ' dibeli.'; persist(); unlock('shopper'); if (save.owned.skin.length >= 4) unlock('fashion'); 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);
|
|
}
|
|
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);
|
|
|
|
window.Meta = { skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } };
|
|
})();
|