Kode tantangan dan kebiasaan bermain lokal (RENCANA 7.5 dan 7.6): challenge.js memuat penyandi kode 8 karakter (XXXX-XXXX, 30 simbol tanpa 0 1 I L O U, pemeriksa 15 bit) untuk pemain, lawan, arena, tingkat, dan satu aturan khusus (adu cepat, satu ronde, lawan gesit, tanpa blok, pukulan berat); dialog Masukkan kode lewat tombol KODE TANTANGAN di menu utama, undangan lewat ?kode= di alamat, SALIN KODE dan TAUTAN di layar arena dan layar hasil; kait satu baris di game.js (begin, tune, roundStart, roundEnd, finish, hud, tanpa blok) dan menu.js (decorate, pending); statistik kebiasaan bermain di localStorage (karakter, mode, arena, lawan) dengan bagian Kebiasaan bermainmu di Koleksi, tombol HAPUS STATISTIK, dan catatan privasi di About; catatan mesin di ENGINE_NOTES

This commit is contained in:
rizkyaz0 2026-10-05 14:46:55 +07:00
parent 3c9980bb09
commit 2dda2845ac
6 changed files with 513 additions and 4 deletions

68
challenge.css Normal file
View File

@ -0,0 +1,68 @@
/* Kode tantangan dan kebiasaan bermain (challenge.js, RENCANA 7.5 dan 7.6). Tinggi jendela terendah yang didukung 520 px (ponsel lanskap 844x390 juga dijaga):
tombol-tombol kecil memakai ruang yang sudah ada (absolut di bilah atas, atau di kolom kanan yang lebih rendah dari kolom kirinya), sehingga tinggi layar tidak bertambah. */
/* ---- Menu utama: tombol KODE TANTANGAN di sebelah TUTORIAL (pojok kiri atas), di luar alur bilah .home-top ---- */
#front-menu .home-top{position:relative}
#front-menu .ch-entry{position:absolute;left:4.5%;top:50%;z-index:1;display:inline-flex;align-items:center;gap:8px;min-height:26px;padding:3px 14px 3px 10px;background:#102b37c4;border:1px solid #67e4dd66;border-left:3px solid #67e4dd;color:#f6eedb;font:700 13px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:2.5px;cursor:pointer;transform:translateY(-50%) skewX(-7deg);transition:background .14s,border-color .14s,transform .14s}
#front-menu .tut-entry~.ch-entry{left:calc(4.5% + 142px)}
#front-menu .ch-entry>*{transform:skewX(7deg)}
#front-menu .ch-entry i{font-style:normal;font-size:12px;color:#67e4dd}
#front-menu .ch-entry:hover,#front-menu .ch-entry:focus-visible{background:#9be9e2;color:#102734;border-color:#d8fffb;transform:translateY(-50%) skewX(-7deg) translateX(4px)}
#front-menu .ch-entry:hover i,#front-menu .ch-entry:focus-visible i{color:#102734}
@media(max-width:560px){#front-menu .ch-entry{font-size:11px;letter-spacing:1.5px;padding:3px 9px 3px 7px}#front-menu .tut-entry~.ch-entry{left:calc(4.5% + 112px)}}
html.touch #front-menu .ch-entry{min-height:30px}
/* ---- Layar arena: baris ATURAN / SALIN KODE / TAUTAN di kolom kanan .match-rules (kolom kiri berisi tombol tingkat dan lebih tinggi, jadi tinggi baris tidak naik) ---- */
#front-menu .ch-tools{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:5px;margin-top:6px;letter-spacing:0;text-align:left}
#front-menu .ch-code{display:none;padding:4px 8px;font:700 13px/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#cfe9e2;background:#07151d99;border:1px dashed #9fd3c455;user-select:all}
@media(min-width:900px){#front-menu .ch-code{display:inline-block}}
#front-menu .ch-btn{margin:0;padding:4px 9px;font:700 11px/1.15 Rajdhani,Arial,sans-serif;letter-spacing:1.4px;color:#e6ecea;background:#13303d;border:1px solid #7dada177;cursor:pointer;white-space:nowrap}
#front-menu .ch-btn small{display:inline;margin:0 5px 0 0;font-size:9px;letter-spacing:1px;color:#9fb8bc}
#front-menu .ch-btn:hover,#front-menu .ch-btn:focus-visible{background:#e6c78f;color:#102734;border-color:#fff0c8}
#front-menu .ch-btn:hover small,#front-menu .ch-btn:focus-visible small{color:#35505a}
#front-menu .ch-btn.on{border-color:#e6c78f;color:#f3d796;background:#2a3b3a}
#front-menu .ch-btn.on:hover,#front-menu .ch-btn.on:focus-visible{color:#102734;background:#e6c78f}
@media(max-height:420px){#front-menu .ch-tools{margin-top:3px;gap:4px}#front-menu .ch-btn{padding:2px 7px;font-size:10px}#front-menu .ch-btn small{font-size:8px}#front-menu .ch-code{padding:2px 6px;font-size:11px}}
@media(pointer:coarse) and (min-height:421px){#front-menu .ch-btn{min-height:26px}}
/* ---- Layar hasil: SALIN KODE / TAUTAN di pojok kanan atas kepala hasil (tombol STATISTIK ada di pojok kanan bawah) ---- */
#front-menu .rs-head{position:relative}
#front-menu .ch-share{position:absolute;right:0;top:0;z-index:2;display:flex;gap:6px;animation:rs-up .5s .9s both}
@media(max-width:700px){#front-menu .ch-share .ch-btn[data-ch=link]{display:none}}
/* ---- HUD: penanda tantangan tepat di bawah kolom tengah (jam), ikut posisi bilah atas di semua ukuran ---- */
.ch-badge{position:absolute;left:50%;top:100%;transform:translateX(-50%);margin-top:6px;padding:2px 10px;white-space:nowrap;font:700 10px/1.35 Rajdhani,Arial,sans-serif;letter-spacing:1.8px;color:#102734;background:#e6c78f;border:1px solid #fff0c8;box-shadow:0 2px 6px #0007}
@media(max-width:950px){.ch-badge{font-size:9px;letter-spacing:1.2px;padding:1px 7px;margin-top:4px}}
@media(max-height:500px) and (min-width:620px){.ch-badge{margin-top:3px}}
@media(max-width:620px){.ch-badge{font-size:8px;letter-spacing:.8px;padding:1px 5px}}
/* ---- Dialog Masukkan kode / undangan ---- */
#ch-dialog{width:min(470px,calc(100% - 24px));padding:22px 26px 20px}
#ch-dialog h2{font-size:30px;margin:6px 0 4px}
#ch-dialog .ch-lead{margin:4px 0 12px;font-size:15px;line-height:1.4}
#ch-dialog .ch-field{display:flex;align-items:center;gap:12px}
#ch-dialog .ch-field>span{flex:none;font-size:12px;font-weight:700;letter-spacing:2px;color:var(--gold)}
#ch-input{flex:1;min-width:0;padding:8px 12px;font:700 26px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:5px;text-transform:uppercase;color:#fff8e8;background:#0a1620;border:1px solid #8da8b1aa;border-radius:0}
#ch-input::placeholder{color:#5f7782;letter-spacing:5px}
#ch-input:focus{border-color:var(--gold)}
#ch-dialog .ch-preview{min-height:76px;margin:12px 0 4px;padding:9px 12px;font-size:15px;line-height:1.4;color:#b9cbd0;background:#ffffff0a;border-left:3px solid #ffffff2e}
#ch-dialog .ch-preview.ok{border-left-color:#67e4dd;color:#e8f6f1}
#ch-dialog .ch-preview.err{border-left-color:#ff9a7a;color:#ffc7b8}
#ch-dialog .ch-vs{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 10px;font-size:22px;line-height:1.15}
#ch-dialog .ch-vs b,#ch-dialog .ch-line b{color:#fff0c8}
#ch-dialog .ch-vs i{font-style:normal;font-size:13px;letter-spacing:2px;color:var(--gold)}
#ch-dialog .ch-actions{display:flex;align-items:center;gap:12px;margin-top:10px}
#ch-dialog .ch-actions .primary-button{width:auto;flex:1;margin:0}
#ch-dialog .ch-actions .primary-button:disabled{background:#6d6e66;color:#2a3338;cursor:not-allowed}
#ch-dialog .ch-actions .text-button{margin:0;white-space:nowrap}
@media(max-height:480px){#ch-dialog{padding:14px 22px 12px}#ch-dialog h2{font-size:24px;margin:2px 0}#ch-dialog .ch-lead{display:none}#ch-input{font-size:22px;padding:6px 10px}#ch-dialog .ch-preview{min-height:60px;margin-top:8px;padding:6px 10px;font-size:14px}#ch-dialog .ch-vs{font-size:19px}}
/* ---- Koleksi: "Kebiasaan bermainmu" (disisipkan challenge.js sebelum daftar pencapaian) ---- */
.hb-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:8px;margin:12px 0 0}
.hb-grid>div{min-width:0;padding:8px 12px;background:#101c26;border:1px solid #ffffff1c;border-radius:8px}
.hb-grid dt{font-size:13px;letter-spacing:.06em;color:#9fb3c4;text-transform:uppercase}
.hb-grid dd{margin:2px 0 0;font:700 20px/1.15 Rajdhani,Arial,sans-serif;color:#f6eedb;overflow-wrap:anywhere}
.hb-grid dd small{display:block;margin-top:1px;font-size:14px;font-weight:600;color:#8fa3b3}
/* ---- About: catatan privasi satu baris ---- */
.about-panel>small.ch-about{margin-top:3px;line-height:1.45;color:#8fb0b6}

432
challenge.js Normal file
View File

@ -0,0 +1,432 @@
/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
})();

View File

@ -31,6 +31,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- HUD (game.js): meter ultimate `#ult-player`/`#ult-enemy` (`updateUltMeters`; mengisi/SIAP/waktu mode dari `modeOf`, `bwx.rage`, bentuk FENR; DOM hanya ditulis saat tampilan berubah). Menu jeda `#pause-panel` dua panel; `buildMoveList()` membangun ulang Daftar gerakan tiap `setPaused(true)`, pilihan lipat diingat di `localStorage['konoha.moveList']`.
- Mode cerita Arcade dan bos Bayangan (Tahap 3.5 dan 3.6): `content/story.js` (`window.STORY`: `intro[id]` 2-3 kalimat, `between[A][B]` 1-2 kalimat (atau fungsi), `ending[id]` 3 kalimat, `boss` = pembuka, ucapan VS/kalah/menang, kalimat akhir, gelar; `generic` cadangan dengan penampung `{name}` `{rival}` `{n}`) dan `story.js` + `story.css` (`window.Story`). `Story.show({kind: intro|between|boss|ending, player, enemy, step, total, stage, boss}, onDone)` menampilkan kartu layar penuh (`#story-screen`, z-index 45, di atas layar VS 40) dan memanggil onDone tepat satu kali: klik/ketuk/Spasi/Enter/Esc setelah 0,5 dtk, atau otomatis (kartu antar-lawan sekitar 4 dtk, kartu panjang lebih lama); selama tampil semua tombol ditahan dan `Meta` menahan toast (`Story.active`). Mengembalikan false tanpa kartu bila tidak ada teks, jadi alur tetap jalan tanpa cerita. `meta.js` Arcade: `startRun` (kartu pembuka) -> `nextFight` -> menang -> `betweenFights` (kartu antar-lawan, panggung lawan berikutnya jadi latar kartu) ... lawan terakhir kalah -> `arcadeCleared` (hadiah tamat 500 + pencapaian langsung dibayar seperti dulu) -> kartu bos -> `startBoss` -> `finishRun(true, 'win'|'lose')` -> kartu akhir -> layar hasil. Survival tidak memakai cerita. `run.boss` = null | 'pending' | 'fight'. Menang atas Bayangan: +1000 koin, pencapaian BAYANGAN (+50), gelar PENAKLUK BAYANGAN (`Meta.save.title`, tampil di kartu akhir dan plat pemenang layar hasil); kalah: Arcade tetap tamat, tanpa bonus bos. Uji manual: `?arcade=N` memendekkan Arcade menjadi N lawan. Bayangan = `startMatch({player: X, enemy: X, level: 'excellent', shadow: true})` (id sama; state `hero` dan `dummy` tetap terpisah): `setShadowFoe` mengatur `BALANCE.dummyMax` (+40%), `foeDamage` menambah 10% pada damage ke pemain (pembungkus `__game.receiveHit` di akhir game.js dan `dotDamage`), `dummyHpShown` memberi skala HP dasar untuk bar dan penentu waktu habis (`MatchRules.tick`), `drawDummy` memakai `ctx.filter` gelap + lapisan ungu `drawShadowTint` + aura, percikan ungu lewat `fxr()` di `updateVisuals`. HUD: nama BAYANGAN dengan label nama asli, kelas `.shadow-foe` (story.css). VS: game.js memanggil `Story.decorateVs(info)` tepat setelah `VS.show` (nama 'BAYANGAN <NAMA>', ucapan bos); layar hasil: `ResultView.view` membaca `run.boss` (Bayangan di sisi kalah; kalah dari Bayangan tetap tampil TAMAT). `__game.shadowFoe()` melaporkan keadaan.
- Statistik pertandingan (RENCANA 5.5): `stats.js` (`window.MatchStats`) + `stats.css`; tag-nya ada di baris sendiri sebelum `</main>` di `index.html`. Statistik pemain 1 per pertandingan versus (juga 2 pemain, Arcade, Survival, Bayangan; latihan = `reset(false)`, tanpa statistik). Kait di game.js, semuanya satu baris dengan `window.MatchStats?.`: `reset(versus)` di `startMatch` dan `reset`; `tick(dt, match, hero.hp, dummy.damage, BALANCE.dummyMax)` di awal `advanceMatch` (damage keluar/diterima = selisih HP per langkah, durasi = jumlah waktu fase fight, ronde = peralihan fight -> ko); `cast(jenis, hero.action)` di `cast` dan `startAttack`; `hit(hitSource(), counter, comboHits, hero.action)` di `hitDummy` (juga jalur chip blok CPU) dan `hit(null, ...)` di burst `dotDamage`; `parry()`/`block()` di `heroGuard`; `finish(info)` di event 'complete' menaruh ringkasan di `info.stats` (dibawa `Meta.award` dan `FrontEnd.showResult`). Akurasi jurus = pemakaian yang kena sekali; pukulan proyektil/ultimate dikaitkan ke jurus terakhir dalam jendela waktu (`MatchStats.WINDOW`), jadi bila dua jurus bertumpuk bisa meleset satu. Layar hasil: `ResultView.view` memanggil `MatchStats.resultHtml` (tombol STATISTIK di kanan baris skor + kartu `.rs-ms` yang menutup kolom tengah di atas tombol aksi, diatur `MatchStats.mount`; Esc menutup kartu lebih dulu daripada kembali ke menu); tanpa `info.stats` (data uji) tidak ada tombol. Simpanan seumur hidup: `Meta.award` memanggil `MatchStats.commit(save.stats, r.stats)` (kunci baru `dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches`; `bestCombo` ikut dinaikkan ke kombo terbaik seluruh ronde; simpanan lama tanpa kunci terbaca 0), tampil di Koleksi lewat `MatchStats.lifetimeHtml`. Uji manual cepat: `MatchStats.snapshot()` di konsol saat pertandingan berjalan. Belum ada skenario otomatis khusus statistik (mode ringan, diuji manual).
- Kode tantangan dan kebiasaan bermain (RENCANA 7.5 dan 7.6): `challenge.js` (`window.Challenge`, `window.Habits`) + `challenge.css`; tag-nya sebelum `menu.js` di `index.html`. **Kode** = 8 karakter dari 30 simbol tanpa 0 1 I L O U (contoh `TNNB-TPZM`), isinya 24 bit (versi, variasi, aturan, tingkat, arena, lawan, pemain) + 15 bit pemeriksa yang dicampur; `Challenge.encode({player, enemy, stage, level, rule})` / `Challenge.decode(teks)` (menerima huruf kecil, spasi, tanda hubung, tautan `?kode=`, atau kalimat yang memuat kode; hasil `{ok, cfg}` atau `{ok:false, err, msg}`). Daftar karakter, arena, tingkat, dan aturan di berkas itu menentukan arti kode: **urutannya tidak boleh diubah, hanya ditambah di belakang** (karakter/arena baru yang belum didaftarkan ikut otomatis di belakang menurut urutan deklarasi). **Aturan khusus** (`RULES`, satu per kode): `blitz` Adu cepat (satu ronde, HP awal separuh), `one` Satu ronde, `fast` Lawan gesit (jalan +25%, cooldown pulih +35%), `noblock` Tanpa blok (blok dan parry mati untuk kedua sisi), `heavy` Pukulan berat (damage +50% kedua sisi); kuncinya hanya hidup di kode, tidak disimpan di localStorage (jadi tidak bentrok dengan aturan harian). **Alur**: tombol KODE TANTANGAN di pojok kiri atas menu utama (di sebelah TUTORIAL, absolut supaya tinggi menu tidak berubah) membuka dialog `#ch-dialog` yang memvalidasi sambil mengetik dan langsung memulai (`Challenge.start(cfg)` memanggil `startMatch({... mode:'versus', vs:{label}, codeRule:{rule, code}})`); `?kode=XXXXXXXX` di alamat membuka dialog yang sama sebagai undangan setelah boot (parameter dihapus dari alamat sesudahnya; `mobile.html` meneruskan query ke iframe). Layar arena (hanya versus melawan CPU) mendapat baris `ATURAN` (menggilir aturan, berlaku juga untuk LET'S FIGHT dan MAIN LAGI lewat `Challenge.pending(mode, p2)` di `menu.js start()`; menu utama mengosongkannya) / `SALIN KODE` / `TAUTAN` di kolom kanan `.match-rules`; layar hasil mendapat `SALIN KODE` / `TAUTAN` di pojok kanan atas `.rs-head` (tidak untuk Arcade, Survival, dua pemain, atau Bayangan). Penyalinan: `navigator.clipboard`, lalu `execCommand('copy')`, lalu `prompt()`. **Kait** (semua `window.Challenge?.x?.()`, dibungkus `guard`: galat hanya dicatat di konsol, tanpa berkas ini game berjalan seperti dulu): game.js `begin(options)` di `startMatch` sesudah validasi dan sebelum `refreshPassives` (menetapkan aturan aktif, judul layar VS "TANTANGAN · ...", penanda `.ch-badge` di bawah jam HUD, dan mencatat kebiasaan), `tune(sideMods)` di akhir `refreshPassives` (aturan = pengali kunci `passiveMod`), `roundStart(hero, dummy, BALANCE)` di akhir `resetCombat` (HP awal), `roundEnd(match, event)` dan `finish(info)` di `advanceMatch` (satu ronde menetapkan `match.winner` sebelum efek K.O. akhir; `finish` mengingat pengaturan untuk SALIN KODE dan mematikan `info.perfect` pada aturan satu ronde supaya bonus/pencapaian BERSIH tidak murah), `hud(match)` di `updateMatchHud` (teks "SATU RONDE"), `noBlock` di `press` (tombol L) dan `heroGuard`; menu.js `decorate(root, screen, {player, enemy, stage, level, mode, p2, runKind, result})` di akhir `render()`. `startMatch` mengabaikan `codeRule` untuk latihan, dua pemain, dan Bayangan. Aturan tidak memakai pengacak, jadi hasil pertandingan tetap deterministik. **Kebiasaan bermain** (`Habits`): `localStorage['konoha.habits.v1']` = `{v:1, n, ch, mode, stage, foe}` (hitungan per karakter yang dipilih, mode, arena, dan karakter lawan; tanpa waktu dan identitas), dicatat di `begin()` tiap pertandingan dimulai (latihan juga; Bayangan tidak dihitung sebagai lawan; mode dikenali dari `options.mode`, `vs.p2`, awalan label ARCADE/SURVIVAL, `codeRule`, atau `options.kind` untuk mode baru). Terpisah dari `Meta.save`, jadi simpanan lama tak tersentuh dan data rusak dibaca sebagai nol. Bagian "Kebiasaan bermainmu" (karakter, mode, arena favorit, lawan paling sering, tombol HAPUS STATISTIK) disisipkan ke tab Koleksi oleh `MutationObserver` pada `#meta-overlay` (sebelum daftar pencapaian; `meta.js` tidak diubah); catatan privasi satu baris di layar About lewat `decorate`. Belum ada skenario otomatis dan UI belum diperiksa di peramban oleh penulisnya (hanya `node --check`, smoke, dan pemeriksaan penyandi sekali pakai: 5.880 kombinasi bolak-balik tanpa tabrakan): uji manual di 1024x520 dan 844x390. `precache.js`/`files.json` perlu dibangun ulang agar `challenge.js`/`challenge.css` ikut tersimpan offline. Konsol: `Challenge.state()`, `Challenge.open()`, `Habits.data()`.
- `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter.
- `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang.

View File

@ -45,6 +45,7 @@
sideStyle.player = P.styleKey(styles.player ?? window.Meta?.styleOf?.(selectedCharacter));
sideStyle.enemy = P.styleKey(styles.enemy === 'acak' ? P.randomStyle(styleRand) : styles.enemy === 'p2' ? window.Meta?.styleOf?.(opponentCharacter) : styles.enemy);
sideMods.player = P.merge(selectedCharacter, sideStyle.player); sideMods.enemy = P.merge(opponentCharacter, sideStyle.enemy);
window.Challenge?.tune?.(sideMods); // aturan khusus kode tantangan mengalikan angka kedua sisi (challenge.js)
}
// Pertandingan sungguhan melawan CPU (punya layar VS, bukan P2) memakai gaya acak untuk CPU; dua pemain memakai gaya pilihan tiap karakter; latihan dan uji tanpa options.styles memakai Seimbang.
function matchStyles(options, mode) { const o = options.styles || {}; return { player: o.player, enemy: o.enemy ?? (mode === 'versus' && options.vs ? (options.vs.p2 ? 'p2' : 'acak') : 'seimbang') }; }
@ -239,6 +240,7 @@
const {player='arco',enemy='fenr',stage='bellora',level='medium',mode='versus'}=options; p2Mode=false; keys2.clear();
if(!playable().includes(player)||!playable().includes(enemy)||!Object.hasOwn(Rules.stages,stage)||!Object.hasOwn(Rules.difficulties,level)||!['versus','training'].includes(mode))return false;
if(!images['stage-'+stage])return false;
window.Challenge?.begin?.(options); // kode tantangan (challenge.js): aturan khusus (options.codeRule) dan statistik kebiasaan; harus sebelum refreshPassives
selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY;refreshPassives(matchStyles(options,mode));
setShadowFoe(!!options.shadow&&mode==='versus');
aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins();
@ -294,6 +296,7 @@
for(const event of Rules.tick(match,dt,hero.hp,dummyHpShown())) {
if(event.type==='cue')emitRoundCue(event.cue,event.round);
if(event.type==='round-end') {
window.Challenge?.roundEnd?.(match,event); // aturan "satu ronde": menetapkan match.winner sebelum efek K.O. akhir (challenge.js)
matchBest=Math.max(matchBest,bestCombo);clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0;
if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;}
if(!event.timeout&&event.winner!=='draw'||event.doubleKO)startKoFx(event.doubleKO?'both':event.winner==='player'?'dummy':'hero',!!match.winner);
@ -308,6 +311,7 @@
if(event.type==='complete'){
// bestCombo (kombo terbaik ronde terakhir) tetap dasar bonus koin; matchBest (seluruh ronde) hanya untuk layar hasil.
const info={winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,bestCombo,matchBest:Math.max(matchBest,bestCombo),p2:p2Mode,perfect:match.winner==='player'&&match.enemyWins===0,boss:shadowFoe};
window.Challenge?.finish?.(info); // mengingat pengaturan untuk SALIN KODE; "bersih" tidak berlaku untuk aturan satu ronde (challenge.js)
info.stats=window.MatchStats?.finish(info)||null;
const metaHandled=!!window.Meta?.award?.(info);
if(!metaHandled)window.FrontEnd?.showResult({...info,mode:'versus',award:window.Meta?.lastAward||null});
@ -326,6 +330,7 @@
$('#round-call-title').textContent=!show?'':match.phase==='intro'?(match.fightCue?'FIGHT':`ROUND ${match.round}`):match.endText||'K.O.';
$('#round-call-detail').textContent=match?.phase==='ko'?(match.lastWinner==='draw'?'DRAW · ROUND REPLAY':(match.lastWinner==='player'?selectedCharacter:opponentCharacter).toUpperCase()+' WINS THE ROUND'):'FIRST TO TWO WINS';
canvas.dataset.mode=match?.mode||'training';canvas.dataset.matchPhase=match?.phase||'fight';canvas.dataset.round=String(match?.round||1);canvas.dataset.opponent=opponentCharacter;canvas.dataset.difficulty=difficulty;canvas.dataset.stage=stageId;
window.Challenge?.hud?.(match); // teks ronde untuk aturan "satu ronde" (challenge.js)
}
function canAct() { return ready && !menuOpen && (!match || match.phase === 'fight') && !paused && !settingsOpen && hero.hurtTime <= 0 && hero.hp > 0; }
@ -397,7 +402,7 @@
if (key === 'escape') { if (settingsOpen) closeSettings(); else setPaused(!paused); return; }
if (key === 'r') { reset(); return; }
if (p2Mode && P2_KEYS.includes(key)) { p2Press(key); return; }
if (!canAct() || keys.has(key)) return;
if (!canAct() || keys.has(key) || (key === 'l' && window.Challenge?.noBlock)) return; // aturan "tanpa blok" (challenge.js): L tidak berbuat apa-apa
keys.add(key); logInput(key);
if (key === 'l') { hero.parry = .16 * passiveMod(hero, 'parry'); }
if (key === 'j') startThrow();
@ -541,6 +546,7 @@
// Sesudahnya hero.autoGuard menahan sikap blok 0,3 dtk (lihat step) supaya bingkai blok terlihat dan pukulan berantai ikut ditahan.
const autoGuardReady = () => !!window.Access?.autoBlock && hero.grounded && hero.hurtTime <= 0 && (hero.guardBreak || 0) <= 0 && hero.doublePose <= 0 && (dummy.x - hero.x) * hero.facing > 0;
function heroGuard(damage, options) {
if (window.Challenge?.noBlock) return false; // aturan "tanpa blok" (challenge.js), termasuk blok otomatis bantuan
const auto = !hero.blocking && !(hero.parry > 0) && autoGuardReady();
if (!(hero.blocking || hero.parry > 0 || auto) || hero.action || options?.unblockable) return false;
if (auto || hero.autoGuard > 0) hero.autoGuard = .3;
@ -596,6 +602,7 @@
particles.length = effects.length = projectiles.length = damageNumbers.length = 0; Feel.reset(); hero.flash = dummy.flash = 0; koSeen.dummy = koSeen.hero = false; totalDamage = comboHits = bestCombo = comboDamage = comboTimer = hitstop = trauma = cinematic = 0; clearInput(); setPaused(false); announce('TRAINING READY', 1.25);
bwReset(); squad = null; enemySquad = null; parade = null; enemyParade = null; flock = null; enemyFlock = null; serpent = null; enemySerpent = null; quake = null; enemyQuake = null; finale = null; enemyFinale = null; skyfall = null; enemySkyfall = null; orrery = null; enemyOrrery = null; sunroar = null; enemySunroar = null; delivery = null; enemyDelivery = null; tortoise = null; enemyTortoise = null;
for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0;
window.Challenge?.roundStart?.(hero, dummy, BALANCE); // HP awal aturan khusus kode tantangan (challenge.js)
}
function reset() {
if(Rules)match=Rules.create(match?.mode||'training');

View File

@ -2,7 +2,7 @@
<html lang="id">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
<link rel="manifest" href="manifest.webmanifest"><link rel="icon" type="image/png" sizes="192x192" href="assets/pwa/icon-192.png"><link rel="apple-touch-icon" href="assets/pwa/apple-touch-icon.png"><link rel="stylesheet" href="touchlayout.css"><script src="pwa.js"></script><script src="touchlayout.js" defer></script>
<link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"><link rel="stylesheet" href="mystery.css"><link rel="stylesheet" href="input.css"><link rel="stylesheet" href="access.css"><link rel="stylesheet" href="tutorial.css"></head>
<link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"><link rel="stylesheet" href="mystery.css"><link rel="stylesheet" href="input.css"><link rel="stylesheet" href="access.css"><link rel="stylesheet" href="tutorial.css"><link rel="stylesheet" href="challenge.css"></head>
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span><p class="boot-quote"></p></div></div><script src="content/loading.js"></script><script src="content/changelog.js"></script><script src="brand.js"></script><script src="content/story.js"></script><script src="story.js"></script><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
<header class="combat-header">
@ -27,6 +27,6 @@
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
<!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js -->
<link rel="stylesheet" href="stats.css"><script src="stats.js"></script>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="tutorial.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="tutorial.js"></script><script src="challenge.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>

View File

@ -76,6 +76,7 @@
}
bind();attachBackground();window.Brand?.decorate(root,screen);window.Mystery?.decorate(root,screen,hover,lockedSlots);
window.Tutorial?.decorate(root,screen); // tombol TUTORIAL kecil di pojok kiri atas layar utama (tutorial.js)
window.Challenge?.decorate(root,screen,{player,enemy,stage,level,mode,p2,runKind,result}); // tombol KODE TANTANGAN, SALIN KODE, catatan About (challenge.js)
}
function updatePreview(play=true){
root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover));
@ -92,7 +93,7 @@
lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;if(runKind){const k=runKind;runKind=null;window.Meta?.startRun(k,player);document.querySelector('#arena').focus();return;}step=1;hover=ids.find(x=>x!==player)||player;}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000);
}
function back(){if(lockIn)return;game.stopAnnouncer();sfx('back',true);if(screen==='arena'){screen='select';step=1;hover=enemy;}else if(screen==='select'&&step===1){step=0;hover=player;}else screen='home';render();}
function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode,vs:mode==='versus'?{p2}:false})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}}
function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode,vs:mode==='versus'?{p2}:false,codeRule:window.Challenge?.pending?.(mode,p2)})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}}
// Opens the shop/locker/collection. If meta.js is missing (stale cached index.html) it is loaded on demand.
function openMeta(tab){
const go=()=>{try{window.Meta.open(tab);}catch(e){console.error(e);alert('Menu gagal dibuka: '+e.message);}};