${s.name}
Warna baru untuk semua karakter.
/* 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 ? `K${esc(m.name)}${info.cd > 0 ? Math.ceil(info.cd) + 's' : 'SIAP'}` : ''; 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 = `
`; }; // 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 ``; }).join(''); } function render() { let body = ''; if (tab === 'shop') body = renderShop(); else if (tab === 'locker') body = renderLocker(); else body = renderBook(); root.innerHTML = ``; } function renderShop() { const skins = Object.entries(SKINS).filter(([k]) => k !== 'standar').map(([k, s]) => { const own = save.owned.skin.includes(k); return `Warna baru untuk semua karakter.
${m.desc}
Cooldown ${m.cd} detik · tombol K${o.desc}
${fname(who)} · ${o.slot === 'skill1' ? 'tombol I' : 'tombol O'}Menang ${st.wins} · Kalah ${st.losses} · Bersih ${st.perfects} · Kombo terbaik ${st.bestCombo} · Total koin ${save.earned}
${window.MatchStats?.lifetimeHtml(st) || ''}Memakai suara bawaan browser.
`; } 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: