433 lines
34 KiB
JavaScript
433 lines
34 KiB
JavaScript
/* Kode tantangan (RENCANA 7.5) dan kebiasaan bermain lokal (RENCANA 7.6). Gaya: challenge.css. Tanpa jaringan dan tanpa pustaka luar.
|
||
|
||
KODE TANTANGAN (window.Challenge)
|
||
- Bentuk: 8 karakter dari 30 simbol yang tidak membingungkan (2-9 dan A-Z tanpa I, L, O, U), tampil XXXX-XXXX. Saat mengetik, huruf kecil, spasi, dan tanda hubung diabaikan;
|
||
tempelan berupa tautan (?kode=...) atau pesan panjang ditelusuri sampai ketemu kode yang lolos pemeriksaan.
|
||
- Isi 24 bit (versi 2, variasi 2, aturan 4, tingkat 2, arena 4, lawan 5, pemain 5) + 15 bit pemeriksa, lalu dicampur supaya kode yang mirip tidak tampak mirip. Salah ketik lolos
|
||
pemeriksa dengan peluang sekitar 1 : 32.000 dan masih harus menghasilkan karakter, arena, dan aturan yang ada, jadi praktis selalu ditolak ("kode tidak valid").
|
||
"Variasi" dipakai penyandi untuk menghindari kata kasar yang kebetulan terbentuk (BLOCKED); tanpa itu selalu 0.
|
||
- Daftar karakter (BASE_FIGHTERS), arena (BASE_STAGES), tingkat (LEVELS), dan aturan (RULES) menentukan arti kode: urutannya TIDAK BOLEH diubah, hanya ditambah di belakang.
|
||
Karakter/arena baru yang belum ada di daftar ikut otomatis di belakangnya menurut urutan deklarasi (window.FIGHTERS, MatchRules.stages); sebaiknya tetap dimasukkan ke daftar di sini.
|
||
- Aturan khusus (RULES): satu per kode, kecil dan aman, hanya mengubah angka lewat kait sekecil mungkin di game.js. Kuncinya (blitz, one, fast, noblock, heavy) hanya hidup di berkas ini
|
||
dan di dalam kode; tidak ada yang disimpan di localStorage, jadi tidak bentrok dengan aturan tantangan harian (daily.js).
|
||
- Alur: Challenge.open() = dialog "Masukkan kode" (tombol KODE TANTANGAN di pojok kiri atas menu utama); ?kode=XXXXXXXX di alamat membuka dialog yang sama sebagai undangan
|
||
(isi sudah terisi, tinggal TERIMA); Challenge.start(cfg) memulai pertandingan. Layar arena (menu.js) mendapat baris ATURAN / SALIN KODE / TAUTAN, layar hasil tombol SALIN KODE / TAUTAN.
|
||
Aturan yang dipilih di layar arena berlaku juga untuk tombol LET'S FIGHT dan MAIN LAGI (menu.js start() memanggil Challenge.pending); kembali ke menu utama mengosongkannya.
|
||
Kait di game.js (semua window.Challenge?.x?.(), jadi tanpa berkas ini game berjalan seperti biasa): begin(options) di startMatch sesudah validasi (juga mencatat kebiasaan bermain),
|
||
tune(sideMods) di akhir refreshPassives, roundStart(hero, dummy, BALANCE) di akhir resetCombat, roundEnd(match, event) dan finish(info) di advanceMatch, hud(match) di updateMatchHud,
|
||
dan noBlock di press (tombol L) serta heroGuard. Kait di menu.js: decorate(root, screen, ctx) di akhir render() dan pending(mode, p2) di start(). startMatch menerima opsi
|
||
codeRule: {rule, code}; hanya berlaku untuk versus melawan CPU (bukan dua pemain, bukan Bayangan). Semua kait dibungkus guard: galat hanya dicatat di konsol.
|
||
Tidak memakai Math.random/Date.now: aturan tidak mengubah pengacak pertandingan.
|
||
|
||
KEBIASAAN BERMAIN (window.Habits)
|
||
- localStorage['konoha.habits.v1'] = {v:1, n, ch:{idKarakter:jumlah}, mode:{...}, stage:{...}, foe:{...}}. Dicatat di begin() tiap pertandingan dimulai (latihan juga, Bayangan tidak
|
||
dihitung sebagai "lawan"). Tanpa waktu, tanpa identitas, tanpa jaringan. Terpisah dari Meta.save, jadi simpanan lama tidak tersentuh; data rusak atau kosong dibaca sebagai nol.
|
||
- Bagian "Kebiasaan bermainmu" disisipkan ke tab Koleksi lewat MutationObserver (meta.js tidak diubah), lengkap dengan tombol HAPUS STATISTIK. Catatan privasi di layar About lewat decorate().
|
||
Konsol: Challenge.state(), Challenge.encode({player, enemy, stage, level, rule}), Challenge.decode('XXXX-XXXX'), Habits.data(). */
|
||
(() => {
|
||
'use strict';
|
||
const KEY_HABITS = 'konoha.habits.v1';
|
||
const $ = s => document.querySelector(s);
|
||
const esc = t => String(t == null ? '' : t).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
const guard = (fn, fallback) => (...a) => { try { return fn(...a); } catch (e) { console.error('Challenge:', e); return fallback; } };
|
||
const store = {
|
||
get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } },
|
||
set(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* penyimpanan penuh atau diblokir: statistik hanya hidup di sesi ini */ } },
|
||
del(k) { try { localStorage.removeItem(k); } catch (_) { /* abaikan */ } }
|
||
};
|
||
const sfx = kind => { try { window.__game?.menuSound?.(kind); } catch (_) { /* suara hanya hiasan */ } };
|
||
const say = msg => { try { window.Meta?.toast?.(msg); } catch (_) { /* toast hanya hiasan */ } };
|
||
|
||
// ---------- nama dan ketersediaan (sama dengan yang dipakai menu.js) ----------
|
||
const fighterName = id => window.FIGHTERS?.[id]?.name || (id === 'browo' ? 'BRO WO' : String(id || '?').toUpperCase());
|
||
const hasFighter = id => !!(window.FIGHTERS?.[id] || (id === 'browo' && window.Browo && window.BROWO_MANIFEST));
|
||
const stageName = id => window.MatchRules?.stages?.[id]?.name || String(id || '?');
|
||
const hasStage = id => !!window.MatchRules?.stages && Object.hasOwn(window.MatchRules.stages, id);
|
||
|
||
// ---------- daftar kode (urutan tetap, hanya ditambah di belakang) ----------
|
||
const BASE_FIGHTERS = ['browo', 'megachan', 'sonofsolo', 'ethanolman', 'anice', 'soloman', 'gufron'];
|
||
const BASE_STAGES = ['bellora', 'sunspire', 'harbor', 'elderwood', 'moonrise'];
|
||
const LEVELS = ['easy', 'medium', 'hard', 'excellent'];
|
||
const LEVEL_NAME = { easy: 'Mudah', medium: 'Sedang', hard: 'Sulit', excellent: 'Sangat sulit' };
|
||
const fighterList = () => BASE_FIGHTERS.concat(Object.keys(window.FIGHTERS || {}).filter(id => !BASE_FIGHTERS.includes(id)));
|
||
const stageList = () => BASE_STAGES.concat(Object.keys(window.MatchRules?.stages || {}).filter(id => !BASE_STAGES.includes(id)));
|
||
// Aturan khusus. Kolom: rounds = jumlah ronde penentu (default 3 ronde, menang 2), hp = pecahan HP awal kedua petarung, mods = pengali kunci passiveMod (passives.js KEYS)
|
||
// per sisi, noBlock = blok dan parry tidak berlaku. Menambah aturan: tambahkan di BELAKANG dan jangan menggeser yang lama.
|
||
const FAST = { walk: 1.25, cd: 1.35 }, HEAVY = 1.5, HALF = .5, pct = v => Math.round((v - 1) * 100);
|
||
const RULES = [
|
||
{ id: 'none', name: 'Tanpa aturan khusus', short: 'BIASA', desc: 'Pertandingan seperti biasa.' },
|
||
{ id: 'blitz', name: 'Adu cepat', short: 'ADU CEPAT', desc: 'Satu ronde saja dan nyawa kedua petarung tinggal separuh.', rounds: 1, hp: HALF },
|
||
{ id: 'one', name: 'Satu ronde', short: 'SATU RONDE', desc: 'Hanya satu ronde. Siapa menang, dialah juaranya.', rounds: 1 },
|
||
{ id: 'fast', name: 'Lawan gesit', short: 'LAWAN GESIT', desc: `Lawan berjalan ${pct(FAST.walk)}% lebih cepat dan jurusnya pulih ${pct(FAST.cd)}% lebih cepat.`, mods: { enemy: FAST } },
|
||
{ id: 'noblock', name: 'Tanpa blok', short: 'TANPA BLOK', desc: 'Blok dan parry tidak berlaku, untuk kedua petarung.', noBlock: true, mods: { enemy: { parry: 0 } } },
|
||
{ id: 'heavy', name: 'Pukulan berat', short: 'PUKULAN BERAT', desc: `Semua damage naik ${pct(HEAVY)}%, untuk kedua petarung.`, mods: { player: { dmgOut: HEAVY }, enemy: { dmgOut: HEAVY } } }
|
||
];
|
||
const ruleOf = id => RULES.find(r => r.id === id) || RULES[0];
|
||
|
||
// ---------- penyandi ----------
|
||
const ALPHA = '23456789ABCDEFGHJKMNPQRSTVWXYZ', BASE = ALPHA.length, TOTAL = 2 ** 39; // 30 simbol; 24 bit isi + 15 bit pemeriksa = 39 bit < 30^8
|
||
const BLOCKED = ['SEX', 'ASS', 'SETAN', 'SATAN', 'BANGSAT', 'KAMPRET', 'KEPARAT', 'JAHANAM']; // kata yang tidak boleh muncul kebetulan di kode
|
||
const mix = x => { x = Math.imul(x ^ (x >>> 16), 0x7feb352d); x = Math.imul(x ^ (x >>> 15), 0x846ca68b); return (x ^ (x >>> 16)) >>> 0; };
|
||
const check15 = p => mix((p ^ 0x2f6b9d35) >>> 0) & 0x7fff;
|
||
const mask24 = c => mix((c + 0x1b873593) >>> 0) & 0xffffff;
|
||
function pack(p) {
|
||
const c = check15(p), m = (p ^ mask24(c)) & 0xffffff;
|
||
let n = m * 32768 + c, out = '';
|
||
for (let i = 0; i < 8; i++) { const d = n % BASE; out = ALPHA[d] + out; n = (n - d) / BASE; }
|
||
return out;
|
||
}
|
||
// 24 bit isi, atau -1 bila pemeriksa tidak cocok.
|
||
function unpack(s) {
|
||
let n = 0; for (const ch of s) n = n * BASE + ALPHA.indexOf(ch);
|
||
if (n >= TOTAL) return -1;
|
||
const c = n % 32768, m = (n - c) / 32768, p = (m ^ mask24(c)) & 0xffffff;
|
||
return check15(p) === c ? p : -1;
|
||
}
|
||
const pretty = code => { const c = String(code || ''); return c.length > 4 ? c.slice(0, 4) + '-' + c.slice(4) : c; }; // K7M2QX9T -> K7M2-QX9T (juga untuk kode yang belum lengkap)
|
||
// cfg = {player, enemy, stage, level, rule?} -> 'K7M2QX9T' (tanpa tanda hubung) atau null bila ada yang belum bisa disandikan.
|
||
function encode(cfg) {
|
||
const fl = fighterList(), sl = stageList();
|
||
const a = fl.indexOf(cfg?.player), b = fl.indexOf(cfg?.enemy), s = sl.indexOf(cfg?.stage), l = LEVELS.indexOf(cfg?.level), r = RULES.findIndex(x => x.id === (cfg?.rule || 'none'));
|
||
if (a < 0 || a > 31 || b < 0 || b > 31 || s < 0 || s > 15 || l < 0 || r < 0 || r > 15) return null;
|
||
const body = a | (b << 5) | (s << 10) | (l << 14) | (r << 16);
|
||
let code = '';
|
||
for (let variant = 0; variant < 4; variant++) { code = pack(body | (variant << 20)); if (!BLOCKED.some(w => code.includes(w))) break; }
|
||
return code;
|
||
}
|
||
// Mengambil kode dari apa pun yang diketik atau ditempel: kode polos, tautan dengan ?kode=..., atau kalimat yang memuat kode.
|
||
function extract(raw) {
|
||
const text = String(raw == null ? '' : raw).trim(); if (!text) return '';
|
||
const link = /[?&#]kode=([A-Za-z0-9-]+)/i.exec(text); if (link) return link[1].toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||
const flat = text.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||
if (flat.length <= 8) return flat;
|
||
const up = text.toUpperCase(), re = /([A-Z0-9]{4})[\s-]?([A-Z0-9]{4})/g; let m;
|
||
while ((m = re.exec(up))) { const c = m[1] + m[2]; if ([...c].every(ch => ALPHA.includes(ch)) && unpack(c) >= 0) return c; re.lastIndex = m.index + 1; }
|
||
return flat;
|
||
}
|
||
const fail = (err, msg, soft) => ({ ok: false, err, msg, soft: !!soft });
|
||
// -> {ok:true, code, cfg:{player, enemy, stage, level, rule}} atau {ok:false, err, msg, soft}; soft = belum selesai diketik (bukan galat).
|
||
function decode(raw) {
|
||
const s = extract(raw);
|
||
if (!s) return fail('empty', 'Ketik atau tempel kode tantangannya dulu.', true);
|
||
if (s.length > 8) return fail('long', 'Tidak ketemu kode tantangan yang valid di teks ini. Kode terdiri dari 8 huruf dan angka.');
|
||
if ([...s].some(ch => !ALPHA.includes(ch))) return fail('chars', 'Ada karakter yang tidak dipakai di kode tantangan (0, 1, I, L, O, U). Periksa lagi ya.');
|
||
if (s.length < 8) return fail('short', `Kodenya baru ${s.length} dari 8 karakter.`, true);
|
||
const p = unpack(s); if (p < 0) return fail('check', 'Kode ini tidak valid. Mungkin ada huruf atau angka yang salah ketik.');
|
||
if (p >>> 22) return fail('version', 'Kode ini dibuat oleh versi game yang lebih baru. Muat ulang atau perbarui game dulu.');
|
||
const fl = fighterList(), sl = stageList();
|
||
const cfg = { player: fl[p & 31], enemy: fl[(p >> 5) & 31], stage: sl[(p >> 10) & 15], level: LEVELS[(p >> 14) & 3], rule: RULES[(p >> 16) & 15]?.id };
|
||
if (!cfg.rule || !hasFighter(cfg.player) || !hasFighter(cfg.enemy) || !cfg.stage || !hasStage(cfg.stage)) return fail('unknown', 'Kode ini memakai karakter, arena, atau aturan yang belum ada di game ini.');
|
||
return { ok: true, code: s, cfg };
|
||
}
|
||
function shareUrl(code) {
|
||
try { const u = new URL(location.href); u.search = ''; u.hash = ''; if (u.protocol !== 'file:') u.pathname = u.pathname.replace(/index\.html$/, ''); return u.href + '?kode=' + code; } catch (_) { return '?kode=' + code; }
|
||
}
|
||
// Papan klip: API modern, lalu execCommand, lalu jendela prompt supaya pemain bisa menyalin sendiri. Hasil true = sudah tersalin.
|
||
async function copyText(text) {
|
||
try { if (navigator.clipboard?.writeText && window.isSecureContext !== false) { await navigator.clipboard.writeText(text); return true; } } catch (_) { /* coba cadangan */ }
|
||
try {
|
||
const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
|
||
document.body.append(ta); ta.select(); ta.setSelectionRange(0, text.length);
|
||
const ok = document.execCommand('copy'); ta.remove(); if (ok) return true;
|
||
} catch (_) { /* coba prompt */ }
|
||
try { window.prompt('Salin teks ini (Ctrl+C), lalu bagikan:', text); } catch (_) { /* tanpa prompt: tidak ada yang bisa dilakukan */ }
|
||
return false;
|
||
}
|
||
|
||
// ---------- keadaan dan kait mesin ----------
|
||
let selected = 'none'; // aturan yang dipilih di layar arena atau dari kode; dibaca menu.js start() lewat pending()
|
||
let active = null; // {rule, code} selama pertandingan berkode berjalan; kosong di pertandingan biasa
|
||
let last = { rule: 'none' }; // pertandingan terakhir (untuk SALIN KODE di layar hasil)
|
||
const cur = () => (active ? ruleOf(active.rule) : null);
|
||
const label = r => 'TANTANGAN' + (r && r.id !== 'none' ? ' · ' + r.short : '');
|
||
const els = {}, q = sel => (els[sel] && els[sel].isConnected ? els[sel] : (els[sel] = $(sel)));
|
||
const setText = (sel, t) => { const e = q(sel); if (e && e.textContent !== t) e.textContent = t; };
|
||
function paintBadge(rule) {
|
||
const host = $('.match-center'); if (!host) return;
|
||
let b = host.querySelector('.ch-badge');
|
||
if (!rule) { b?.remove(); return; }
|
||
if (!b) { b = document.createElement('div'); b.className = 'ch-badge'; b.setAttribute('role', 'note'); host.append(b); }
|
||
b.textContent = label(rule); b.title = rule.desc;
|
||
}
|
||
const begin = guard(options => {
|
||
const o = options || {}, mode = o.mode === undefined ? 'versus' : o.mode, c = o.codeRule;
|
||
track(o); // kebiasaan bermain (bagian bawah); galatnya tidak boleh menghentikan sisa kait ini
|
||
active = c && typeof c === 'object' && mode === 'versus' && !(o.vs && o.vs.p2) && !o.shadow ? { rule: ruleOf(c.rule).id, code: String(c.code || '') } : null;
|
||
if (active && o.vs) { const vs = typeof o.vs === 'object' ? o.vs : {}; if (!vs.label) o.vs = { ...vs, label: label(ruleOf(active.rule)) }; } // judul layar VS
|
||
last = { rule: active ? active.rule : 'none' };
|
||
paintBadge(active ? ruleOf(active.rule) : null);
|
||
});
|
||
// refreshPassives: aturan dikalikan ke angka pasif tiap sisi (kunci dan arti sama dengan passiveMod).
|
||
const tune = guard(mods => {
|
||
const r = cur(); if (!r?.mods || !mods) return;
|
||
for (const side of ['player', 'enemy']) { const src = r.mods[side], dst = mods[side]; if (src && dst) for (const k of Object.keys(src)) dst[k] = (dst[k] === undefined ? 1 : dst[k]) * src[k]; }
|
||
});
|
||
// resetCombat (awal pertandingan dan tiap ronde): HP awal. Maksimum HP tetap, jadi bilah dan penentu waktu habis memakai skala yang sama; yang berubah hanya titik awalnya.
|
||
const roundStart = guard((hero, dummy, bal) => {
|
||
const r = cur(); if (!r?.hp || !hero || !dummy || !bal) return;
|
||
hero.hp = Math.max(1, Math.round(bal.heroMax * r.hp));
|
||
dummy.damage = bal.dummyMax - Math.max(1, Math.round(bal.dummyMax * r.hp));
|
||
});
|
||
// Satu ronde: begitu ronde berakhir dengan pemenang, pertandingan dianggap selesai (seri tetap mengulang ronde). MatchRules.tick sudah menaruh match.winner = null di sini.
|
||
const roundEnd = guard((m, e) => { const r = cur(); if (r?.rounds === 1 && m && !m.winner && e && e.winner && e.winner !== 'draw') m.winner = e.winner; });
|
||
// Pertandingan selesai: ingat pengaturannya untuk SALIN KODE di layar hasil. "Bersih" (menang tanpa kehilangan ronde) tidak berlaku bila hanya ada satu ronde.
|
||
const finish = guard(info => {
|
||
if (!info) return;
|
||
const r = cur();
|
||
last = { player: info.player, enemy: info.enemy, stage: info.stage, level: info.level, rule: r ? r.id : 'none' };
|
||
if (r?.rounds === 1) info.perfect = false;
|
||
});
|
||
// Teks HUD untuk aturan satu ronde (updateMatchHud menulis ulang tiap frame; ini menimpanya sesudah itu, hanya bila perlu).
|
||
const hud = guard(m => {
|
||
const r = cur(); if (r?.rounds !== 1 || !m || m.mode !== 'versus') return;
|
||
setText('#mode-label', 'SATU RONDE');
|
||
setText('#round-wins-player', m.playerWins ? '◆' : '◇'); setText('#round-wins-enemy', m.enemyWins ? '◆' : '◇');
|
||
const d = q('#round-call-detail'); if (d && d.textContent === 'FIRST TO TWO WINS') d.textContent = 'SATU RONDE MENENTUKAN';
|
||
});
|
||
const pending = (mode, p2) => (selected !== 'none' && mode === 'versus' && !p2 ? { rule: selected } : null);
|
||
try { new MutationObserver(() => { if (active && document.body.classList.contains('in-menu')) active = null; }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) { /* tanpa pengamat: pertandingan berikutnya tetap menimpa */ }
|
||
|
||
// ---------- memulai pertandingan dari kode ----------
|
||
function start(cfg, code) {
|
||
const g = window.__game; if (!g || !cfg) return false;
|
||
const rule = ruleOf(cfg.rule), before = selected; selected = rule.id;
|
||
let ok = false;
|
||
try { window.Meta?.cancelRun?.(); ok = !!g.startMatch({ player: cfg.player, enemy: cfg.enemy, stage: cfg.stage, level: cfg.level, mode: 'versus', vs: { label: label(rule) }, codeRule: { rule: rule.id, code: code || '' } }); } catch (e) { console.error(e); }
|
||
if (!ok) selected = before;
|
||
return ok;
|
||
}
|
||
|
||
// ---------- dialog Masukkan kode / undangan ----------
|
||
let dlg = null, parsed = null;
|
||
function summaryHtml(cfg) {
|
||
const rule = ruleOf(cfg.rule);
|
||
return `<div class="ch-vs"><b>${esc(fighterName(cfg.player))}</b><i>VS</i><b>${esc(fighterName(cfg.enemy))}</b></div>`
|
||
+ `<div class="ch-line">Arena <b>${esc(stageName(cfg.stage))}</b> · Tingkat <b>${esc(LEVEL_NAME[cfg.level])}</b></div>`
|
||
+ `<div class="ch-line">Aturan <b>${esc(rule.name)}</b>${rule.id === 'none' ? '' : ' · ' + esc(rule.desc)}</div>`;
|
||
}
|
||
function refresh() {
|
||
const input = dlg.querySelector('#ch-input'), box = dlg.querySelector('#ch-preview'), go = dlg.querySelector('#ch-go');
|
||
const r = parsed = decode(input.value);
|
||
if (r.ok) { box.className = 'ch-preview ok'; box.innerHTML = summaryHtml(r.cfg); go.disabled = false; }
|
||
else { box.className = 'ch-preview ' + (r.soft ? 'hint' : 'err'); box.textContent = r.msg; go.disabled = true; }
|
||
}
|
||
// Selama mengetik kode polos, tanda hubung di tengah dipasang otomatis (hanya bila kursor di ujung, supaya penyuntingan di tengah tidak melompat).
|
||
function reformat(input) {
|
||
const v = input.value;
|
||
if (!/^[A-Za-z0-9\s-]*$/.test(v) || input.selectionStart !== v.length) return;
|
||
const flat = v.toUpperCase().replace(/[^A-Z0-9]/g, ''); if (flat.length > 8) return;
|
||
const t = pretty(flat); if (t !== v) input.value = t;
|
||
}
|
||
function closeDialog() { if (dlg?.open) { try { dlg.close(); } catch (_) { dlg.removeAttribute('open'); } } }
|
||
function go() {
|
||
if (!dlg || !parsed) return;
|
||
const input = dlg.querySelector('#ch-input');
|
||
if (!parsed.ok) { sfx('locked'); input.focus(); return; }
|
||
sfx('confirm');
|
||
if (start(parsed.cfg, parsed.code)) { closeDialog(); $('#arena')?.focus(); return; }
|
||
const box = dlg.querySelector('#ch-preview'); box.className = 'ch-preview err'; box.textContent = 'Aset game belum siap. Tunggu sebentar, lalu coba lagi.';
|
||
}
|
||
function build() {
|
||
dlg = document.createElement('dialog'); dlg.id = 'ch-dialog'; dlg.setAttribute('aria-labelledby', 'ch-title');
|
||
dlg.innerHTML = '<div class="dialog-kicker" id="ch-kicker"></div><h2 id="ch-title"></h2><p class="ch-lead" id="ch-lead"></p>'
|
||
+ '<label class="ch-field"><span>KODE</span><input id="ch-input" type="text" autocomplete="off" autocapitalize="characters" spellcheck="false" maxlength="240" placeholder="XXXX-XXXX" aria-describedby="ch-preview"></label>'
|
||
+ '<div id="ch-preview" class="ch-preview" role="status" aria-live="polite"></div>'
|
||
+ '<div class="ch-actions"><button type="button" class="primary-button" id="ch-go" disabled></button><button type="button" class="text-button" id="ch-close"></button></div>';
|
||
document.body.append(dlg);
|
||
const input = dlg.querySelector('#ch-input');
|
||
input.addEventListener('input', () => { reformat(input); refresh(); });
|
||
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
||
// Selama dialog terbuka tidak ada tombol yang boleh bocor ke menu atau game (Esc ditutup di sini sendiri, karena game.js memanggil preventDefault pada Esc).
|
||
dlg.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); closeDialog(); } });
|
||
dlg.addEventListener('keyup', e => e.stopPropagation());
|
||
dlg.addEventListener('cancel', e => { e.preventDefault(); closeDialog(); });
|
||
dlg.querySelector('#ch-go').addEventListener('click', go);
|
||
dlg.querySelector('#ch-close').addEventListener('click', () => { sfx('back'); closeDialog(); });
|
||
}
|
||
// opts.raw = isi awal (kode, tautan, atau pesan); opts.invite = tampil sebagai undangan dari tautan ?kode=.
|
||
const open = guard((opts = {}) => {
|
||
if (!dlg || !dlg.isConnected) build();
|
||
const invite = !!opts.invite, input = dlg.querySelector('#ch-input');
|
||
dlg.dataset.invite = invite ? '1' : '';
|
||
dlg.querySelector('#ch-kicker').textContent = invite ? 'KONOHA FIGHTING / TANTANGAN' : 'KONOHA FIGHTING / KODE TANTANGAN';
|
||
dlg.querySelector('#ch-title').textContent = invite ? 'Ada yang menantangmu!' : 'Masukkan kode';
|
||
dlg.querySelector('#ch-lead').textContent = invite ? 'Temanmu mengirim tautan tantangan. Cek pengaturannya, lalu mulai kalau sudah siap.' : 'Tempel atau ketik kode dari temanmu. Pertandingan langsung dimulai dengan pengaturan yang sama.';
|
||
dlg.querySelector('#ch-go').innerHTML = (invite ? 'TERIMA TANTANGAN' : 'MULAI TANTANGAN') + ' <span>↗</span>';
|
||
dlg.querySelector('#ch-close').textContent = invite ? 'Nanti saja' : 'Batal';
|
||
const raw = String(opts.raw || '').trim(), code = extract(raw);
|
||
input.value = code && code.length <= 8 ? pretty(code) : raw;
|
||
refresh();
|
||
try { dlg.showModal(); } catch (_) { dlg.setAttribute('open', ''); }
|
||
(invite && parsed?.ok ? dlg.querySelector('#ch-go') : input).focus();
|
||
});
|
||
// Tautan ?kode=...: tawarkan tantangannya sesudah layar muat selesai, lalu hapus parameter dari alamat supaya muat ulang tidak menawarkan lagi.
|
||
function dropParam() {
|
||
const strip = w => { try { const u = new URL(w.location.href); if (!u.searchParams.has('kode')) return; u.searchParams.delete('kode'); w.history.replaceState(null, '', u.pathname + u.search + u.hash); } catch (_) { /* alamat tidak bisa diubah: biarkan */ } };
|
||
strip(window); try { if (window.parent !== window) strip(window.parent); } catch (_) { /* induk lintas asal */ }
|
||
}
|
||
try {
|
||
const raw = new URLSearchParams(location.search).get('kode');
|
||
// Kode di tautan yang valid tampil sebagai undangan; yang tidak valid atau kosong membuka dialog biasa (dengan pesan galatnya) supaya bisa diperbaiki.
|
||
if (raw !== null) Promise.resolve(window.AETHER_PRELOAD).then(() => setTimeout(() => { open({ invite: decode(raw).ok, raw }); dropParam(); }, 650));
|
||
} catch (_) { /* tanpa parameter */ }
|
||
|
||
// ---------- tombol di layar menu ----------
|
||
function flash(btn, text) {
|
||
if (!btn) return; const old = btn.dataset.ch0 ?? (btn.dataset.ch0 = btn.textContent); btn.textContent = text; clearTimeout(btn._ch);
|
||
btn._ch = setTimeout(() => { if (btn.isConnected) btn.textContent = old; }, 1700);
|
||
}
|
||
async function share(kind, cfg, btn) {
|
||
const code = encode(cfg); if (!code) { sfx('locked'); return; }
|
||
const text = kind === 'link' ? shareUrl(code) : pretty(code), ok = await copyText(text);
|
||
sfx('confirm');
|
||
if (ok) { flash(btn, 'TERSALIN ✓'); say(kind === 'link' ? 'Tautan tantangan disalin. Kirim ke temanmu!' : 'Kode ' + pretty(code) + ' disalin. Kirim ke temanmu!'); }
|
||
}
|
||
function cycleRule() {
|
||
const i = RULES.findIndex(r => r.id === selected), n = RULES[(i + 1) % RULES.length]; selected = n.id; sfx('move');
|
||
say(n.id === 'none' ? 'Aturan khusus dimatikan.' : n.name.toUpperCase() + ': ' + n.desc);
|
||
}
|
||
function addEntry(root) {
|
||
const slot = root.querySelector('.home-top'); if (!slot || slot.querySelector('.ch-entry')) return;
|
||
const b = document.createElement('button'); b.type = 'button'; b.className = 'ch-entry'; b.setAttribute('aria-label', 'Masukkan kode tantangan dari teman');
|
||
b.innerHTML = '<i aria-hidden="true">#</i><span>KODE TANTANGAN</span>';
|
||
b.onclick = () => { sfx('confirm'); open(); };
|
||
slot.append(b);
|
||
}
|
||
function toolsHtml(cfg) {
|
||
const rule = ruleOf(selected), code = encode({ ...cfg, rule: rule.id }), pc = pretty(code);
|
||
return `<code class="ch-code" title="Kode tantangan untuk pengaturan ini">${esc(pc)}</code>`
|
||
+ `<button type="button" class="ch-btn ch-rule${rule.id === 'none' ? '' : ' on'}" data-ch="rule" title="${esc(rule.name + ': ' + rule.desc)}" aria-label="Aturan khusus: ${esc(rule.name)}. Tekan untuk ganti aturan."><small>ATURAN</small>${esc(rule.short)}</button>`
|
||
+ `<button type="button" class="ch-btn" data-ch="code" title="Salin kode ${esc(pc)}">SALIN KODE</button>`
|
||
+ `<button type="button" class="ch-btn" data-ch="link" title="Salin tautan yang membuka tantangan ini">TAUTAN</button>`;
|
||
}
|
||
function addArenaTools(root, ctx) {
|
||
const host = root.querySelector('.match-rules');
|
||
if (!host || !ctx || ctx.mode !== 'versus' || ctx.p2 || ctx.runKind || !encode({ ...ctx, rule: 'none' })) return;
|
||
const cfg = { player: ctx.player, enemy: ctx.enemy, stage: ctx.stage, level: ctx.level };
|
||
const head = host.querySelector(':scope > b'), sub = host.querySelector(':scope > span'), was = [head?.textContent, sub?.textContent]; // "FIRST TO 2 WINS / BEST OF 3 ROUNDS"
|
||
const box = document.createElement('div'); box.className = 'ch-tools'; box.setAttribute('role', 'group'); box.setAttribute('aria-label', 'Kode tantangan'); host.append(box);
|
||
const paint = () => {
|
||
box.innerHTML = toolsHtml(cfg);
|
||
const one = ruleOf(selected).rounds === 1; // teks ronde mengikuti aturan satu ronde
|
||
if (head) head.textContent = one ? 'SATU RONDE' : was[0];
|
||
if (sub) sub.textContent = one ? 'SATU RONDE MENENTUKAN · 90 DETIK' : was[1];
|
||
};
|
||
paint();
|
||
box.addEventListener('click', e => {
|
||
const b = e.target.closest('[data-ch]'); if (!b) return;
|
||
if (b.dataset.ch === 'rule') { cycleRule(); paint(); box.querySelector('[data-ch=rule]')?.focus(); }
|
||
else share(b.dataset.ch, { ...cfg, rule: selected }, b);
|
||
});
|
||
}
|
||
function addResultTools(root, ctx) {
|
||
const r = ctx?.result, head = root.querySelector('.rs-head');
|
||
if (!r || !head || r.run || r.p2 || r.boss) return;
|
||
const cfg = { player: r.player, enemy: r.enemy, stage: r.stage, level: r.level, rule: last.rule || 'none' }, code = encode(cfg); if (!code) return;
|
||
const rule = ruleOf(cfg.rule), box = document.createElement('div'), pc = pretty(code);
|
||
box.className = 'ch-share'; box.setAttribute('role', 'group'); box.setAttribute('aria-label', 'Bagikan tantangan ini');
|
||
box.innerHTML = `<button type="button" class="ch-btn" data-ch="code" title="Salin kode ${esc(pc)}">SALIN KODE</button><button type="button" class="ch-btn" data-ch="link" title="Salin tautan yang membuka tantangan ini">TAUTAN</button>`;
|
||
box.addEventListener('click', e => { const b = e.target.closest('[data-ch]'); if (b) share(b.dataset.ch, cfg, b); });
|
||
head.append(box);
|
||
if (rule.id !== 'none') { const k = head.querySelector('.rs-kicker'); if (k) k.textContent += ' · ' + rule.short; }
|
||
}
|
||
function addAboutNote(root) {
|
||
const panel = root.querySelector('.about-panel'); if (!panel || panel.querySelector('.ch-about')) return;
|
||
const n = document.createElement('small'); n.className = 'ch-about'; n.textContent = 'Semua data permainan (koin, pencapaian, statistik kebiasaan bermain) hanya tersimpan di browser ini dan tidak dikirim ke mana pun.';
|
||
const credit = panel.querySelector(':scope > small'); if (credit) credit.after(n); else panel.querySelector('.menu-primary')?.before(n);
|
||
}
|
||
// menu.js memanggil ini di akhir tiap render(); ctx = {player, enemy, stage, level, mode, p2, runKind, result}.
|
||
const decorate = guard((root, screen, ctx) => {
|
||
if (!root) return;
|
||
if (screen === 'home') { selected = 'none'; addEntry(root); }
|
||
else if (screen === 'arena') addArenaTools(root, ctx);
|
||
else if (screen === 'result') addResultTools(root, ctx);
|
||
else if (screen === 'about') addAboutNote(root);
|
||
});
|
||
|
||
// ---------- kebiasaan bermain ----------
|
||
const MODE_NAME = { versus: 'VS Komputer', training: 'Latihan', arcade: 'Arcade', survival: 'Survival', '2p': 'Dua Pemain', challenge: 'Tantangan', daily: 'Tantangan harian' };
|
||
const KEY_OK = /^[\w-]{1,40}$/;
|
||
const cleanMap = m => { const out = {}; if (m && typeof m === 'object' && !Array.isArray(m)) for (const [k, v] of Object.entries(m)) { const n = Math.floor(+v); if (KEY_OK.test(k) && n > 0 && n < 1e9) out[k] = n; } return out; };
|
||
function loadHabits() {
|
||
try {
|
||
const raw = JSON.parse(store.get(KEY_HABITS) || 'null');
|
||
if (raw && typeof raw === 'object') { const h = { v: 1, ch: cleanMap(raw.ch), mode: cleanMap(raw.mode), stage: cleanMap(raw.stage), foe: cleanMap(raw.foe) }; h.n = Math.max(Math.floor(+raw.n) || 0, Object.values(h.mode).reduce((a, b) => a + b, 0)); return h; }
|
||
} catch (_) { /* data rusak: mulai dari nol */ }
|
||
return { v: 1, n: 0, ch: {}, mode: {}, stage: {}, foe: {} };
|
||
}
|
||
let hab = loadHabits();
|
||
function habitMode(o, challenge) {
|
||
if (o.mode === 'training') return 'training';
|
||
if (challenge) return 'challenge';
|
||
const vs = o.vs && typeof o.vs === 'object' ? o.vs : null, tag = String(vs?.label || '').toUpperCase();
|
||
if (vs?.p2) return '2p';
|
||
if (o.shadow || tag.startsWith('ARCADE')) return 'arcade';
|
||
if (tag.startsWith('SURVIVAL')) return 'survival';
|
||
return typeof o.kind === 'string' && Object.hasOwn(MODE_NAME, o.kind) ? o.kind : 'versus'; // o.kind: mode baru bisa menandai dirinya
|
||
}
|
||
// Dipanggil begin() untuk tiap pertandingan yang benar-benar dimulai. Bayangan (lawan = salinan pemain) tidak dihitung sebagai karakter lawan.
|
||
function recordHabits(o) {
|
||
const mode = o.mode === undefined ? 'versus' : o.mode, ch = !!(o.codeRule && typeof o.codeRule === 'object' && mode === 'versus' && !(o.vs && o.vs.p2) && !o.shadow);
|
||
const bump = (m, k) => { if (typeof k === 'string' && KEY_OK.test(k)) m[k] = (m[k] || 0) + 1; };
|
||
hab.n++; bump(hab.ch, o.player); bump(hab.mode, habitMode(o, ch)); bump(hab.stage, o.stage); if (!o.shadow) bump(hab.foe, o.enemy);
|
||
store.set(KEY_HABITS, JSON.stringify(hab));
|
||
}
|
||
const track = guard(recordHabits);
|
||
// Pilihan teratas satu kelompok: jumlah terbanyak, seri menurut nama kunci (supaya tidak berubah-ubah); yang sudah tidak ada di game dilewati.
|
||
function topOf(map, known, nameOf) {
|
||
let best = null; for (const [k, n] of Object.entries(map)) { if (!known(k)) continue; if (!best || n > best.n || (n === best.n && k < best.k)) best = { k, n }; }
|
||
return best ? { name: nameOf(best.k), n: best.n, pct: hab.n ? Math.round(100 * best.n / hab.n) : 0 } : null;
|
||
}
|
||
const habitSummary = () => ({
|
||
n: hab.n, ch: topOf(hab.ch, hasFighter, fighterName), mode: topOf(hab.mode, k => Object.hasOwn(MODE_NAME, k), k => MODE_NAME[k]),
|
||
stage: topOf(hab.stage, hasStage, stageName), foe: topOf(hab.foe, hasFighter, fighterName)
|
||
});
|
||
function habitsHtml() {
|
||
const s = habitSummary(), tile = (name, v) => `<div><dt>${name}</dt><dd>${v ? `${esc(v.name)}<small>${v.n}× · ${v.pct}%</small>` : '—'}</dd></div>`;
|
||
return '<h3>Kebiasaan bermainmu</h3>' + (s.n
|
||
? `<dl class="hb-grid" aria-label="Kebiasaan bermain">${tile('Karakter favorit', s.ch)}${tile('Mode favorit', s.mode)}${tile('Arena favorit', s.stage)}${tile('Lawan paling sering', s.foe)}</dl>`
|
||
+ `<p class="meta-hint">Dihitung dari ${s.n} pertandingan yang kamu mulai. Hanya tersimpan di browser ini dan tidak dikirim ke mana pun.</p>`
|
||
: '<p class="meta-hint">Belum ada data. Mainkan beberapa pertandingan, lalu kebiasaanmu muncul di sini. Hanya tersimpan di browser ini.</p>')
|
||
+ '<p><button type="button" class="pill danger" data-hb-clear>HAPUS STATISTIK</button></p>';
|
||
}
|
||
function clearHabits() { hab = { v: 1, n: 0, ch: {}, mode: {}, stage: {}, foe: {} }; store.del(KEY_HABITS); }
|
||
// Tab Koleksi dibuat ulang oleh meta.js setiap render; bagian ini disisipkan sesudahnya (sebelum daftar pencapaian) tanpa mengubah meta.js.
|
||
function injectBook(ov) {
|
||
const panel = ov.querySelector('.meta-panel'), on = panel?.querySelector('nav .mt.on'); if (!on || on.dataset.tab !== 'book') return;
|
||
const body = panel.querySelector('.meta-body'); if (!body || body.querySelector('.hb')) return;
|
||
const sec = document.createElement('section'); sec.className = 'hb'; sec.innerHTML = habitsHtml();
|
||
const anchor = [...body.querySelectorAll('h3')].find(h => /^Pencapaian/i.test(h.textContent.trim()));
|
||
if (anchor) anchor.before(sec); else body.append(sec);
|
||
}
|
||
function onBookClick(e, ov) {
|
||
if (!e.target.closest?.('[data-hb-clear]')) return;
|
||
if (!hab.n) { say('Belum ada statistik yang tersimpan.'); return; }
|
||
if (!confirm('Hapus statistik kebiasaan bermain? Koin, skin, dan pencapaianmu tidak ikut terhapus.')) return;
|
||
clearHabits(); const sec = ov.querySelector('.hb'); if (sec) sec.innerHTML = habitsHtml(); say('Statistik kebiasaan bermain dihapus.');
|
||
}
|
||
function watchMeta() {
|
||
const hook = ov => {
|
||
if (ov._chWatch) return; ov._chWatch = true;
|
||
const run = () => { try { injectBook(ov); } catch (e) { console.error(e); } };
|
||
new MutationObserver(run).observe(ov, { childList: true, subtree: true });
|
||
ov.addEventListener('click', e => onBookClick(e, ov)); run();
|
||
};
|
||
const found = document.getElementById('meta-overlay'); if (found) { hook(found); return; }
|
||
const mo = new MutationObserver(() => { const ov = document.getElementById('meta-overlay'); if (ov) { mo.disconnect(); hook(ov); } });
|
||
mo.observe(document.body, { childList: true }); // meta.js membuat hamparan ini saat pertama kali dibuka
|
||
}
|
||
try { watchMeta(); } catch (e) { console.error(e); }
|
||
|
||
window.Challenge = {
|
||
encode: guard(cfg => encode(cfg), null), decode: guard(raw => decode(raw), fail('error', 'Kode tidak bisa dibaca.')), pretty, shareUrl, copyText,
|
||
RULES: RULES.map(r => ({ id: r.id, name: r.name, short: r.short, desc: r.desc })), ruleOf,
|
||
open, start: guard(start, false), pending, decorate,
|
||
begin, tune, roundStart, roundEnd, finish, hud,
|
||
get noBlock() { return !!cur()?.noBlock; },
|
||
get rule() { return selected; }, set rule(id) { selected = ruleOf(id).id; },
|
||
get active() { return active ? { ...active } : null; },
|
||
state: () => ({ selected, active: active ? { ...active } : null, last: { ...last } })
|
||
};
|
||
window.Habits = { data: () => JSON.parse(JSON.stringify(hab)), summary: habitSummary, html: habitsHtml, record: track, clear: clearHabits, KEY: KEY_HABITS };
|
||
})();
|