diff --git a/challenge.css b/challenge.css new file mode 100644 index 0000000..8195965 --- /dev/null +++ b/challenge.css @@ -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} diff --git a/challenge.js b/challenge.js new file mode 100644 index 0000000..9f0e83d --- /dev/null +++ b/challenge.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 `
${esc(fighterName(cfg.player))}VS${esc(fighterName(cfg.enemy))}
` + + `
Arena ${esc(stageName(cfg.stage))} · Tingkat ${esc(LEVEL_NAME[cfg.level])}
` + + `
Aturan ${esc(rule.name)}${rule.id === 'none' ? '' : ' · ' + esc(rule.desc)}
`; + } + 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 = '

' + + '' + + '
' + + '
'; + 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') + ' ↗'; + 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 = 'KODE TANTANGAN'; + 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 `${esc(pc)}` + + `` + + `` + + ``; + } + 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 = ``; + 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) => `
${name}
${v ? `${esc(v.name)}${v.n}× · ${v.pct}%` : '—'}
`; + return '

Kebiasaan bermainmu

' + (s.n + ? `
${tile('Karakter favorit', s.ch)}${tile('Mode favorit', s.mode)}${tile('Arena favorit', s.stage)}${tile('Lawan paling sering', s.foe)}
` + + `

Dihitung dari ${s.n} pertandingan yang kamu mulai. Hanya tersimpan di browser ini dan tidak dikirim ke mana pun.

` + : '

Belum ada data. Mainkan beberapa pertandingan, lalu kebiasaanmu muncul di sini. Hanya tersimpan di browser ini.

') + + '

'; + } + 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 }; +})(); diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 05193cb..7991583 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -32,6 +32,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 ', 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 `` 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//` 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. diff --git a/game.js b/game.js index 4d20cd2..8ba5cf6 100644 --- a/game.js +++ b/game.js @@ -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'); diff --git a/index.html b/index.html index 8533b63..1b7e9f8 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ Konoha Fighting - +
Menyiapkan…

@@ -29,6 +29,6 @@ -
+
diff --git a/menu.js b/menu.js index da2260f..2b8de4e 100644 --- a/menu.js +++ b/menu.js @@ -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);}};