Gabung wf/t3-mystery: slot misteri yang hidup (RENCANA 3.7)

Konflik index.html dan meta.js diselesaikan dengan mempertahankan kedua sisi: mystery.css dimuat sesudah story.css, content/mystery.js dan mystery.js dimuat di antara touch.js dan menu.js seperti di cabangnya, dan daftar pencapaian tersembunyi di Koleksi memuat penasaran_berat sekaligus reuni.
This commit is contained in:
rizkyaz0 2026-10-04 07:39:02 +07:00
commit f103ae8d36
8 changed files with 341 additions and 8 deletions

View File

@ -56,13 +56,15 @@ Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc un
├── menu.css # menu utama, pilih karakter dan arena, hasil, latar kolase, About ├── menu.css # menu utama, pilih karakter dan arena, hasil, latar kolase, About
├── feel.css # peringkat kombo ├── feel.css # peringkat kombo
├── meta.css # toko, karakter, koleksi, toast ├── meta.css # toko, karakter, koleksi, toast
├── mystery.css # gaya slot misteri di layar pilih karakter
├── game.js # mesin: loop, fisika, pertarungan, otak CPU, rendering, HUD ├── game.js # mesin: loop, fisika, pertarungan, otak CPU, rendering, HUD
├── menu.js # menu: alur layar, roster, arena, tingkat kesulitan, kolase latar ├── menu.js # menu: alur layar, roster, arena, tingkat kesulitan, kolase latar
├── match.js # aturan ronde, timer, arena, penyetelan tingkat CPU ├── match.js # aturan ronde, timer, arena, penyetelan tingkat CPU
├── feel.js # rasa kena: hit-stop, getaran, K.O. gerak lambat ├── feel.js # rasa kena: hit-stop, getaran, K.O. gerak lambat
├── meta.js # koin, toko, skin, pencapaian, Arcade/Survival ├── meta.js # koin, toko, skin, pencapaian, Arcade/Survival
├── mystery.js # slot misteri yang hidup: siluet dan petunjuk bertahap dari kemajuan pemain
├── brand.js # kutipan layar loading dan daftar "Yang baru" ├── brand.js # kutipan layar loading dan daftar "Yang baru"
├── content/ # data teks: loading.js (kutipan), changelog.js (Yang baru) ├── content/ # data teks: loading.js (kutipan), changelog.js (Yang baru), mystery.js (petunjuk slot misteri)
├── announcer.js # antrean suara pengumuman ├── announcer.js # antrean suara pengumuman
├── touch.js # kontrol sentuh di layar ├── touch.js # kontrol sentuh di layar
├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets/<id>/manifest.js) ├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets/<id>/manifest.js)

67
content/mystery.js Normal file
View File

@ -0,0 +1,67 @@
/* Slot misteri yang hidup (RENCANA 3.7): window.MYSTERY = [slot, ...], satu entri per petak terkunci 'locked-N' di layar pilih
karakter. Urutan data = urutan petak (MYSTERY[0] untuk locked-0, dan seterusnya). Dibaca mystery.js; gaya di mystery.css.
Bentuk satu slot:
id kunci tetap (dipakai untuk mengingat petunjuk yang sudah dilihat di localStorage['konoha.mystery.seen']); jangan diganti.
color warna aksen '#rrggbb' untuk siluet, lencana, dan panel.
shape benda pada siluet: palu | gunting | pengeras | helm | ponsel (digambar di mystery.js, tanpa gambar baru).
stages 3 sampai 4 petunjuk berurutan. Tahap terbuka bila syaratnya terpenuhi DAN tahap sebelumnya sudah terbuka.
need {type, n}: type dibaca dari Meta.save oleh mystery.js. Yang dikenal: wins (menang), matches (jumlah main),
perfects (menang tanpa kehilangan ronde), combo (kombo terbaik), survival (rekor beruntun Survival),
arcade (Arcade tamat, n = 1), ach (jumlah pencapaian), eggs (kejutan tersembunyi), pairs (dialog rahasia
berpasangan; tahap ini dilewati bila fitur dialog tidak ada), shop (barang toko yang dimiliki), coins (total koin).
tag label singkat huruf besar untuk petunjuk (CIRI, JURUS, JULUKAN, PESAN).
text teka-teki pendek, usahakan di bawah 70 huruf supaya muat dua baris di panel.
Pedoman isi: petunjuk samar dan lucu tentang ciri, julukan, atau jurus fiktif. Jangan menyebut nama tokoh nyata, jangan
menuduh fakta, tanpa unsur agama, tanpa merendahkan siapa pun. Teks di bawah memakai sosok umum (juru palu, gunting pita,
pengeras suara tangan, helm proyek, siaran langsung) sebagai pengisi; ganti bila karakter sebenarnya sudah ditentukan.
Saat petarung sungguhan dirilis menggantikan satu slot, hapus entri slotnya dari daftar ini supaya urutan data tetap sama
dengan petak 'locked-N' yang tersisa. Semua petunjuk terbuka (seluruh slot) = pencapaian PENASARAN BERAT di meta.js. */
window.MYSTERY = [
{
id: 'ketok', color: '#7fe0ff', shape: 'palu',
stages: [
{ need: { type: 'wins', n: 1 }, tag: 'CIRI', text: 'Tak butuh pengeras suara. Cukup sepotong kayu dan satu meja.' },
{ need: { type: 'wins', n: 5 }, tag: 'JURUS', text: 'Jurusnya berbunyi tok-tok-tok. Yang ketiga paling menentukan.' },
{ need: { type: 'wins', n: 12 }, tag: 'JULUKAN', text: 'Dijuluki Si Ketok Tiga. Rapat selesai kalau dia bilang selesai.' },
{ need: { type: 'wins', n: 25 }, tag: 'PESAN', text: 'Sidang diskors dulu. Dia sedang memanaskan palunya.' }
]
},
{
id: 'pita', color: '#efce91', shape: 'gunting',
stages: [
{ need: { type: 'survival', n: 2 }, tag: 'CIRI', text: 'Tiap dia datang, selalu ada pita yang sudah terbentang.' },
{ need: { type: 'survival', n: 5 }, tag: 'JURUS', text: 'Jurusnya ‘Peresmian Mendadak’: gunting dulu, kenalan belakangan.' },
{ need: { type: 'arcade', n: 1 }, tag: 'JULUKAN', text: 'Dijuluki Si Gunting Pita. Aba-abanya selalu tepuk tangan.' }
]
},
{
id: 'volume', color: '#f4a869', shape: 'pengeras',
stages: [
{ need: { type: 'combo', n: 5 }, tag: 'CIRI', text: 'Suaranya sampai ke baris paling belakang, bahkan saat berbisik.' },
{ need: { type: 'perfects', n: 1 }, tag: 'JURUS', text: 'Jurusnya ‘Orasi Tiga Babak’: pembuka, isi, lalu pukulan penutup.' },
{ need: { type: 'combo', n: 10 }, tag: 'JULUKAN', text: 'Dijuluki Si Volume Penuh. Tombol pelannya entah ke mana.' },
{ need: { type: 'perfects', n: 3 }, tag: 'PESAN', text: 'Katanya dia sedang latihan vokal. Siapkan penyumbat telinga.' }
]
},
{
id: 'helm', color: '#8be0a8', shape: 'helm',
stages: [
{ need: { type: 'shop', n: 1 }, tag: 'CIRI', text: 'Di tangannya gulungan denah, di kepalanya selalu ada helm.' },
{ need: { type: 'ach', n: 5 }, tag: 'JURUS', text: 'Jurusnya ‘Tinjau Lokasi’: datang, menunjuk, semua langsung mengangguk.' },
{ need: { type: 'shop', n: 4 }, tag: 'JULUKAN', text: 'Dijuluki Si Helm Kuning. Aman, kecuali untuk lawannya.' },
{ need: { type: 'ach', n: 12 }, tag: 'PESAN', text: 'Dia sedang mengukur arena. Pertanda baik untuk yang menunggu.' }
]
},
{
id: 'siaran', color: '#f08fb8', shape: 'ponsel',
stages: [
{ need: { type: 'eggs', n: 1 }, tag: 'CIRI', text: 'Selalu mencari sudut terbaik, termasuk saat sedang dipukul.' },
{ need: { type: 'eggs', n: 3 }, tag: 'JURUS', text: 'Jurusnya ‘Siaran Langsung’: pukulan sempat tertunda karena sinyal.' },
{ need: { type: 'pairs', n: 2 }, tag: 'JULUKAN', text: 'Dijuluki Si Sinyal Penuh. Selalu ada penonton, walau cuma satu.' },
{ need: { type: 'eggs', n: 4 }, tag: 'PESAN', text: 'Kolom komentar ramai menebak siapa dia. Kamu salah satunya.' }
]
}
];

View File

@ -16,6 +16,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`. - `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`.
- Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2/3.3): data di `content/passives.js` (`window.PASSIVES[id] = {name, desc, flaw, mods}`, `window.STYLES` agresif/seimbang/bertahan), helper dan teks angka otomatis di `passives.js` (`window.Passives`: `merge`, `describe`, `lockerHtml`, `decorateVS`). Mesin: `refreshPassives(styles)` di `startMatch` (juga `selectCharacter`, `setP2`) menggabungkan perk + kelemahan + gaya per sisi ke `sideMods`; semua pembacaan lewat satu titik kait `passiveMod(actor, key, base)` (hero = sisi pemain; selain itu = lawan, CPU atau P2, jadi simetris). Kunci: `dmgOut`, `dmgBasic`, `dmgSkill`, `dmgIn`, `walk`, `cd`, `cdSkill`, `cdUlt`, `parry`, `modeDur` (pengali) dan `regen` (HP/dtk, penambah). Damage dikali di `hitDummy`/`receiveHit`/`dotDamage` lewat `passiveDamage` (dibulatkan dengan sisa yang dibawa, tanpa pengacak); cooldown di loop cooldown `step`/`updateDummy`; jalan di `step`/`updateP2`/`updateFenrAI`; parry di `press('l')` dan peluang blok CPU di `hitDummy`. Gaya pemain disimpan di `Meta.save.style[id]` (`Meta.styleOf/setStyle`, simpanan lama tanpa kunci = Seimbang); `startMatch({styles:{player,enemy}})` menimpa, dan CPU di pertandingan ber-VS memakai gaya acak (pengacak terpisah `styleRand`). Teks tampil di menu Karakter (`renderLocker`), layar VS (`.vs-perk`), dan Daftar gerakan (`passiveRows`). API uji: `__game.passives()`, `__game.passiveMod(actor,key,base)`, `__game.setStyles({player,enemy})`. Catatan: P2 manusia tidak punya tombol blok, jadi kunci `parry` tidak berefek bagi P2. - Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2/3.3): data di `content/passives.js` (`window.PASSIVES[id] = {name, desc, flaw, mods}`, `window.STYLES` agresif/seimbang/bertahan), helper dan teks angka otomatis di `passives.js` (`window.Passives`: `merge`, `describe`, `lockerHtml`, `decorateVS`). Mesin: `refreshPassives(styles)` di `startMatch` (juga `selectCharacter`, `setP2`) menggabungkan perk + kelemahan + gaya per sisi ke `sideMods`; semua pembacaan lewat satu titik kait `passiveMod(actor, key, base)` (hero = sisi pemain; selain itu = lawan, CPU atau P2, jadi simetris). Kunci: `dmgOut`, `dmgBasic`, `dmgSkill`, `dmgIn`, `walk`, `cd`, `cdSkill`, `cdUlt`, `parry`, `modeDur` (pengali) dan `regen` (HP/dtk, penambah). Damage dikali di `hitDummy`/`receiveHit`/`dotDamage` lewat `passiveDamage` (dibulatkan dengan sisa yang dibawa, tanpa pengacak); cooldown di loop cooldown `step`/`updateDummy`; jalan di `step`/`updateP2`/`updateFenrAI`; parry di `press('l')` dan peluang blok CPU di `hitDummy`. Gaya pemain disimpan di `Meta.save.style[id]` (`Meta.styleOf/setStyle`, simpanan lama tanpa kunci = Seimbang); `startMatch({styles:{player,enemy}})` menimpa, dan CPU di pertandingan ber-VS memakai gaya acak (pengacak terpisah `styleRand`). Teks tampil di menu Karakter (`renderLocker`), layar VS (`.vs-perk`), dan Daftar gerakan (`passiveRows`). API uji: `__game.passives()`, `__game.passiveMod(actor,key,base)`, `__game.setStyles({player,enemy})`. Catatan: P2 manusia tidak punya tombol blok, jadi kunci `parry` tidak berefek bagi P2.
- `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`. - `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`.
- Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa.
- `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`. - `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`.
- `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`. - `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`.
- Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache). - Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache).

View File

@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="id"> <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"> <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="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.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"></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"> <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> <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"> <header class="combat-header">
@ -24,6 +24,6 @@
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div> <div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div>
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter &nbsp; R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak &nbsp; ↑ · Lompat &nbsp; N · Serang &nbsp; M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog> <dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter &nbsp; R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak &nbsp; ↑ · Lompat &nbsp; N · Serang &nbsp; M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
<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> <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>
</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="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="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="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="content/mystery.js"></script><script src="mystery.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>

10
menu.js
View File

@ -25,6 +25,8 @@
// shown and then come from the browser cache; holding all of them decoded would waste a phone's image memory. // shown and then come from the browser cache; holding all of them decoded would waste a phone's image memory.
const warm=opened.map(id=>info(id).portrait).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;}); const warm=opened.map(id=>info(id).portrait).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;});
const roster=[...opened,...Array.from({length:Math.max(0,12-opened.length)},(_,i)=>'locked-'+i)]; const roster=[...opened,...Array.from({length:Math.max(0,12-opened.length)},(_,i)=>'locked-'+i)];
// Petak terkunci jadi slot misteri yang hidup (mystery.js, data di content/mystery.js): petunjuk bertahap, slot tetap tidak bisa dipilih.
const lockedSlots=roster.length-opened.length;
// lockIn: the pending step change while a confirmed fighter flashes 3 times (PICK_FLASH s each); input waits for it. // lockIn: the pending step change while a confirmed fighter flashes 3 times (PICK_FLASH s each); input waits for it.
const PICK_FLASH=.3;let lockIn=null,lockTicks=[]; const PICK_FLASH=.3;let lockIn=null,lockTicks=[];
const ids=Object.keys(fighters); const ids=Object.keys(fighters);
@ -53,7 +55,7 @@
const brand='<span class="wordmark">KONOHA<span>FIGHTING</span><i>✦</i></span>',versionText=()=>window.Brand?.version?'VERSI '+window.Brand.version:''; const brand='<span class="wordmark">KONOHA<span>FIGHTING</span><i>✦</i></span>',versionText=()=>window.Brand?.version?'VERSI '+window.Brand.version:'';
const footer=()=>'<footer class="menu-footer"><span><kbd>W A S D</kbd> / <kbd>↑ ↓ ← →</kbd> SELECT &nbsp; <kbd>ENTER</kbd> CONFIRM &nbsp; <kbd>ESC</kbd> BACK</span><span>LOCAL PLAY <i></i> '+(versionText()||'BUILD 02')+'</span></footer>'; const footer=()=>'<footer class="menu-footer"><span><kbd>W A S D</kbd> / <kbd>↑ ↓ ← →</kbd> SELECT &nbsp; <kbd>ENTER</kbd> CONFIRM &nbsp; <kbd>ESC</kbd> BACK</span><span>LOCAL PLAY <i></i> '+(versionText()||'BUILD 02')+'</span></footer>';
function navHeader(title,subtitle){return `<header class="select-header"><button class="menu-back" data-cmd="back" aria-label="Kembali">← <span>BACK</span></button><div><span class="eyebrow">${subtitle}</span><h1>${title}</h1></div>${brand}</header>`;} function navHeader(title,subtitle){return `<header class="select-header"><button class="menu-back" data-cmd="back" aria-label="Kembali">← <span>BACK</span></button><div><span class="eyebrow">${subtitle}</span><h1>${title}</h1></div>${brand}</header>`;}
function fighterPanel(id,side){const f=info(id);return `<div class="fighter-plinth ${side} ${f?'':'unknown'} ${soon(id)?'soon':''}"><div class="plinth-light"></div><span class="side-tag">${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}</span><img class="selection-art" src="${f?.art||'assets/menu/locked.svg'}" alt="${f?.name||'Karakter belum dipilih'}"><div class="fighter-copy"><span>${f?.tag||'AWAITING CHALLENGER'}</span><h2>${f?.name||'???'}</h2><p>${f?.race||'ROSTER EXPANDING'}</p><small>${f?.detail||'A new challenger is on the way.'}</small></div></div>`;} function fighterPanel(id,side){const ms=window.Mystery?.panel(id,side,lockedSlots);if(ms)return ms;const f=info(id);return `<div class="fighter-plinth ${side} ${f?'':'unknown'} ${soon(id)?'soon':''}"><div class="plinth-light"></div><span class="side-tag">${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}</span><img class="selection-art" src="${f?.art||'assets/menu/locked.svg'}" alt="${f?.name||'Karakter belum dipilih'}"><div class="fighter-copy"><span>${f?.tag||'AWAITING CHALLENGER'}</span><h2>${f?.name||'???'}</h2><p>${f?.race||'ROSTER EXPANDING'}</p><small>${f?.detail||'A new challenger is on the way.'}</small></div></div>`;}
function render(){ function render(){
if(lockIn){clearTimeout(lockIn);lockIn=null;}for(const t of lockTicks)clearTimeout(t);lockTicks=[]; if(lockIn){clearTimeout(lockIn);lockIn=null;}for(const t of lockTicks)clearTimeout(t);lockTicks=[];
root.dataset.screen=screen;root.dataset.step=String(step);root.dataset.mode=mode; root.dataset.screen=screen;root.dataset.step=String(step);root.dataset.mode=mode;
@ -72,16 +74,16 @@
} else if(screen==='result'){ } else if(screen==='result'){
const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`; const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`;
} }
bind();attachBackground();window.Brand?.decorate(root,screen); bind();attachBackground();window.Brand?.decorate(root,screen);window.Mystery?.decorate(root,screen,hover,lockedSlots);
} }
function updatePreview(play=true){ function updatePreview(play=true){
root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover)); root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover));
const panel=root.querySelector(step===0?'#player-preview':'#enemy-preview');if(panel)panel.innerHTML=fighterPanel(hover,step===0?'player':'enemy'); const panel=root.querySelector(step===0?'#player-preview':'#enemy-preview');if(panel)panel.innerHTML=fighterPanel(hover,step===0?'player':'enemy');
const f=info(hover),moves=root.querySelector('#fighter-moves');if(moves)moves.innerHTML=f?`<span>${f.style}</span>${soon(hover)?'':`<b>${f.ultimate}</b>`}`:'<span>NEW CHALLENGER</span><b>COMING LATER</b>'; const f=info(hover),moves=root.querySelector('#fighter-moves');if(moves)moves.innerHTML=f?`<span>${f.style}</span>${soon(hover)?'':`<b>${f.ultimate}</b>`}`:'<span>NEW CHALLENGER</span><b>COMING LATER</b>';
const confirm=root.querySelector('.confirm-fighter');if(confirm)confirm.setAttribute('aria-disabled',String(!fighters[hover]));if(play)sfx('move'); const confirm=root.querySelector('.confirm-fighter');if(confirm)confirm.setAttribute('aria-disabled',String(!fighters[hover]));window.Mystery?.preview(root,hover,lockedSlots);if(play)sfx('move');
} }
function focusFighter(id){if(hover===id||lockIn)return;hover=id;updatePreview();} function focusFighter(id){if(hover===id||lockIn)return;hover=id;updatePreview();}
function confirm(){if(lockIn)return;if(!fighters[hover]){sfx('locked',true);root.querySelector('#roster-notice').textContent=soon(hover)?showcase[hover].name+' belum bisa dimainkan.':'Karakter ini belum terbuka.';return;} function confirm(){if(lockIn)return;if(!fighters[hover]){sfx('locked',true);root.querySelector('#roster-notice').textContent=soon(hover)?showcase[hover].name+' belum bisa dimainkan.':(window.Mystery?.notice(hover,lockedSlots)||'Karakter ini belum terbuka.');return;}
// The pick flashes 3 times (tile and big art) with a lock-in chime before the next step, so the screen does not jump. // The pick flashes 3 times (tile and big art) with a lock-in chime before the next step, so the screen does not jump.
sfx('pick',true);game.announceSelection(hover);root.dataset.picking='true'; sfx('pick',true);game.announceSelection(hover);root.dataset.picking='true';
root.querySelector(`[data-fighter="${hover}"]`)?.classList.add('picked');root.querySelector(step===0?'#player-preview':'#enemy-preview')?.classList.add('picked'); root.querySelector(`[data-fighter="${hover}"]`)?.classList.add('picked');root.querySelector(step===0?'#player-preview':'#enemy-preview')?.classList.add('picked');

View File

@ -53,6 +53,7 @@
mbg: ['TRUK LEWAT', 'Panggil truk katering.'], mbg: ['TRUK LEWAT', 'Panggil truk katering.'],
esemka: ['KONVOI', 'Panggil konvoi.'], esemka: ['KONVOI', 'Panggil konvoi.'],
penasaran: ['PENASARAN', 'Ketuk slot tanda tanya berulang kali.'], penasaran: ['PENASARAN', 'Ketuk slot tanda tanya berulang kali.'],
penasaran_berat: ['PENASARAN BERAT', 'Buka semua petunjuk slot misteri.'],
logo: ['TELITI', 'Temukan rahasia di judul.'], logo: ['TELITI', 'Temukan rahasia di judul.'],
parry: ['TEPAT WAKTU', 'Lakukan parry yang berhasil.'], parry: ['TEPAT WAKTU', 'Lakukan parry yang berhasil.'],
arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'], arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'],
@ -271,7 +272,7 @@
} }
function renderBook() { function renderBook() {
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats; const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'logo', 'reuni'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join(''); const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'penasaran_berat', 'logo', 'reuni'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`; return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
} }
function onClick(e) { function onClick(e) {

81
mystery.css Normal file
View File

@ -0,0 +1,81 @@
/* Slot misteri yang hidup (RENCANA 3.7): petak roster 'locked-N' dan panel petunjuk di layar pilih karakter.
Dihias oleh mystery.js. Tanpa gambar baru: siluet adalah SVG sebaris; blur, garis tepi, dan tanda tanya mengikuti --ms-p
(0 sampai 1 = tahap terbuka / jumlah tahap), warna aksen --ms-c per slot. Ukuran petak roster tidak berubah (img tetap di
alur, hanya disembunyikan), semua hiasan memakai posisi absolut. Aturan dibatasi ke #front-menu agar menang atas menu.css. */
/* ---- petak roster ---- */
#front-menu .roster-tile.ms-tile{--ms-c:#7fe0ff;--ms-a:#7fe0ff44;--ms-b:#7fe0ff00;--ms-p:0;--ms-blur:calc((1 - var(--ms-p)) * 3.4px + .15px)}
#front-menu .roster-tile.ms-tile>b{background:#0b1d27d9;color:var(--ms-c);border:1px solid var(--ms-c);padding:1px 4px;letter-spacing:.5px}
#front-menu .roster-tile.ms-tile:not(.ms-on)>b{opacity:.7}
#front-menu .roster-tile.ms-done>b{background:var(--ms-c);color:#09202b}
#front-menu .roster-tile.ms-on{filter:none;background:radial-gradient(ellipse at 50% 44%,#2e5361 0,#13262f 64%,#0b1820 100%)}
#front-menu .roster-tile.ms-on:not(.highlight){border-color:#4d7280}
#front-menu .roster-tile.ms-on>img{visibility:hidden}
#front-menu .roster-tile.ms-on>span{color:var(--ms-c);letter-spacing:1.5px}
#front-menu .roster-tile .ms-sil,#front-menu .roster-tile .ms-q{display:none}
#front-menu .roster-tile.ms-on .ms-sil{display:block;position:absolute;inset:0;pointer-events:none;transform:skewX(4deg)}
#front-menu .roster-tile .ms-svg{display:block;width:100%;height:100%;overflow:visible;filter:blur(var(--ms-blur)) drop-shadow(0 0 4px var(--ms-c))}
#front-menu .roster-tile.ms-on .ms-q{display:block;position:absolute;top:19%;right:4px;font:700 italic 24px/1 Rajdhani,Arial,sans-serif;color:var(--ms-c);text-shadow:0 0 8px var(--ms-c),0 2px 0 #06111a;opacity:calc(1 - var(--ms-p) * .5);pointer-events:none;transform:skewX(4deg)}
#front-menu .roster-tile .ms-new{position:absolute;top:4px;right:5px;width:9px;height:9px;border-radius:50%;background:#ffd66b;box-shadow:0 0 0 0 #ffd66bb3;animation:ms-ping 1.5s ease-out infinite;pointer-events:none}
#front-menu .roster-tile.ms-on.highlight .ms-svg{animation:ms-bob 1.8s ease-in-out infinite alternate}
@keyframes ms-ping{to{box-shadow:0 0 0 7px #ffd66b00}}
@keyframes ms-bob{from{transform:translateY(0)}to{transform:translateY(-3%)}}
/* ---- siluet (dipakai petak dan panel) ---- */
#front-menu .ms-body{fill:#050d13;stroke:var(--ms-c);stroke-width:2.2;stroke-linejoin:round;stroke-opacity:calc(.2 + var(--ms-p) * .6)}
#front-menu .ms-acc{color:var(--ms-c);fill:currentColor;opacity:0}
#front-menu .ms-g .ms-acc{opacity:.4}
#front-menu .ms-f .ms-acc{opacity:.95}
/* ---- panel pratinjau slot yang disorot: siluet di atas, kartu petunjuk di bawah (flex kolom agar tidak saling menimpa) ---- */
#front-menu .fighter-plinth.mystery{--ms-c:#7fe0ff;--ms-a:#7fe0ff44;--ms-b:#7fe0ff00;--ms-p:0;--ms-blur:calc((1 - var(--ms-p)) * 9px + .2px);display:flex;flex-direction:column;padding-top:calc(7vh + 6px)}
#front-menu .mystery .ms-art{position:relative;flex:1 1 0;min-height:56px;margin:0 8%;animation:ms-in .25s ease-out both}
#front-menu .mystery .ms-art:before{content:'';position:absolute;left:0;right:0;bottom:0;top:6%;background:radial-gradient(ellipse at 50% 74%,var(--ms-a),var(--ms-b) 68%)}
#front-menu .mystery .ms-art>svg,#front-menu .mystery .ms-art>img{position:absolute;left:0;top:0;width:100%;height:100%}
#front-menu .mystery .ms-art>img{object-fit:contain;object-position:50% 100%;opacity:.5}
#front-menu .mystery .ms-art>svg{overflow:visible;filter:blur(var(--ms-blur)) drop-shadow(0 0 10px var(--ms-c))}
#front-menu .mystery .ms-glyph{position:absolute;right:2%;top:0;font:700 italic clamp(34px,7vh,64px)/1 Rajdhani,Arial,sans-serif;color:var(--ms-c);text-shadow:0 0 14px var(--ms-c),0 3px 0 #06111a;opacity:calc(1 - var(--ms-p) * .45);animation:ms-pulse 1.4s ease-in-out infinite alternate}
@keyframes ms-in{from{opacity:.25}}
@keyframes ms-pulse{to{transform:translateY(-4px) scale(1.06)}}
#front-menu .mystery .ms-card{position:relative;flex:0 0 auto;max-height:66%;overflow:auto;margin:6px 5% 9px 11%;padding:7px 8px 7px 11px;border-left:3px solid var(--ms-c);background:linear-gradient(90deg,#08151ecc,#08151e66);text-align:left;scrollbar-width:thin}
#front-menu .enemy.mystery .ms-card{margin:6px 11% 9px 5%}
#front-menu .mystery .ms-head{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:clamp(9px,1.5vh,11px);font-weight:700;letter-spacing:2px;color:var(--ms-c)}
#front-menu .mystery .ms-dots{display:flex;gap:5px;flex:none}
#front-menu .mystery .ms-dots i{width:8px;height:8px;border:1px solid var(--ms-c);transform:rotate(45deg)}
#front-menu .mystery .ms-dots i.on{background:var(--ms-c)}
#front-menu .mystery .ms-title{margin:2px 0 4px;font-size:clamp(24px,4.4vh,46px);font-style:italic;line-height:.9;letter-spacing:2px}
#front-menu .mystery .ms-clues{list-style:none;margin:0;padding:0}
#front-menu .mystery .ms-clues li{margin:0 0 4px;font-size:clamp(10px,1.75vh,13.5px);line-height:1.25;color:#d6e2de}
#front-menu .mystery .ms-clues li b{display:inline-block;margin-right:6px;font-size:.72em;letter-spacing:1.5px;color:var(--ms-c)}
#front-menu .mystery .ms-clues .ms-empty{color:#8fa9ad;font-style:italic}
#front-menu .mystery .ms-next{margin-top:6px;padding-top:6px;border-top:1px solid #ffffff1f}
#front-menu .mystery .ms-next small{display:block;font-size:clamp(8px,1.3vh,10px);letter-spacing:2px;color:#9fb8bc}
#front-menu .mystery .ms-next strong{display:block;font-size:clamp(11px,1.9vh,15px);letter-spacing:.5px;color:#f1dfae}
#front-menu .mystery .ms-next em{display:block;font-style:normal;font-size:clamp(9px,1.5vh,11.5px);color:#8fa9ad}
#front-menu .mystery .ms-prog{display:flex;align-items:center;gap:8px;margin-top:4px}
#front-menu .mystery .ms-prog i{display:block;flex:1;height:5px;background:#ffffff1f;border:1px solid #ffffff24}
#front-menu .mystery .ms-prog u{display:block;height:100%;background:var(--ms-c);text-decoration:none}
#front-menu .mystery .ms-prog b{font-size:11px;letter-spacing:1px;color:var(--ms-c)}
/* ---- teks bawah roster untuk slot misteri: satu baris, tidak boleh membungkus (tinggi #fighter-moves tetap) ---- */
#front-menu .fighter-moves.ms-moves>span,#front-menu .fighter-moves.ms-moves b{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#front-menu .fighter-moves.ms-moves b{font-size:clamp(11px,1.9vh,14px);letter-spacing:1px}
#front-menu .roster-notice{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* ---- layar pendek (HP landscape) dan sempit: panel dipadatkan, kartu boleh digulir di dalam panel ---- */
@media(max-height:420px){
#front-menu .fighter-plinth.mystery{padding-top:16px}
#front-menu .mystery .ms-art{min-height:40px;margin:0 4%}
#front-menu .mystery .ms-title,#front-menu .mystery .ms-next em{display:none}
#front-menu .mystery .ms-card{margin:3px 3% 2px 5%;padding:4px 6px 4px 8px;max-height:72%}
#front-menu .enemy.mystery .ms-card{margin:3px 5% 2px 3%}
#front-menu .mystery .ms-clues li{margin-bottom:2px;line-height:1.18}
#front-menu .mystery .ms-next{margin-top:3px;padding-top:3px}
#front-menu .mystery .ms-prog{margin-top:2px}
#front-menu .roster-tile.ms-on .ms-q{font-size:19px}
}
@media(max-width:550px){
#front-menu .fighter-plinth.mystery{padding-top:20px}
#front-menu .mystery .ms-art,#front-menu .mystery .ms-title,#front-menu .mystery .ms-next em{display:none}
#front-menu .mystery .ms-card,#front-menu .enemy.mystery .ms-card{flex:1 1 0;min-height:0;max-height:none;margin:4px 0 0}
}

179
mystery.js Normal file
View File

@ -0,0 +1,179 @@
/* Slot misteri yang hidup (RENCANA 3.7). Lima petak 'locked-N' di layar pilih karakter (menu.js) menjadi sistem petunjuk bertahap.
Data: content/mystery.js (window.MYSTERY). Kemajuan dibaca langsung dari Meta.save. Gaya: mystery.css. Tanpa gambar baru:
siluet digambar sebagai SVG sebaris (bentuk tubuh disalin dari assets/menu/locked.svg), makin tajam tiap petunjuk terbuka,
dan sebuah benda khas muncul samar lalu jelas di dua tahap terakhir.
menu.js memanggil: Mystery.panel (panel pratinjau slot yang disorot), decorate (petak roster, tiap selesai render), preview
(teks di bawah roster saat sorotan berpindah) dan notice (pesan saat slot terkunci ditekan). Slot tetap tidak bisa dipilih.
Fungsi publik tidak pernah melempar galat; bila gagal, layar kembali ke tampilan slot terkunci yang lama.
Semua petunjuk terbuka = pencapaian PENASARAN BERAT (meta.js). ?mystery=N (atau all) di URL hanya mempratinjau tahap N pada
semua slot, tanpa menyimpan apa pun dan tanpa membuka pencapaian. Petunjuk yang sudah dilihat diingat di
localStorage['konoha.mystery.seen'] (hanya untuk titik BARU dan toast; bukan bagian Meta.save). */
(() => {
'use strict';
const SEEN_KEY = 'konoha.mystery.seen', ACH_ALL = 'penasaran_berat', FALLBACK_COLOR = '#7fe0ff';
const PREVIEW = (() => { try { const v = new URLSearchParams(location.search).get('mystery'); if (v === 'all') return 99; const n = v === null || v === '' ? -1 : parseInt(v, 10); return n >= 0 ? n : null; } catch (_) { return null; } })();
const num = v => (typeof v === 'number' && isFinite(v) ? v : 0);
const len = a => (Array.isArray(a) ? a.length : 0);
const count = (o, ok) => (o && typeof o === 'object' ? Object.keys(o).filter(k => o[k] && ok(k)).length : 0);
const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const save = () => window.Meta?.save || {};
const enabled = () => Array.isArray(window.MYSTERY) && window.MYSTERY.length > 0;
const slotIndex = id => { const m = /^locked-(\d+)$/.exec(id || ''); return m ? +m[1] : -1; };
// Syarat yang dikenali data (need.type). read = nilai sekarang dari Meta.save; brief = tujuan satu kalimat (l = sisa, n = target);
// hint = cara mencapainya (opsional); avail = false bila fitur pendukungnya tidak ada (tahap itu dilewati).
const GOALS = {
wins: { read: s => num(s.stats?.wins), brief: l => `Menang ${l} kali lagi` },
matches: { read: s => num(s.stats?.wins) + num(s.stats?.losses), brief: l => `Main ${l} pertandingan lagi` },
perfects: { read: s => num(s.stats?.perfects), brief: l => `Menang bersih ${l} kali lagi`, hint: 'Menang tanpa kehilangan satu ronde pun.' },
combo: { read: s => num(s.stats?.bestCombo), brief: (l, n) => `Capai kombo ${n} pukulan` },
survival: { read: s => num(s.best?.survival), brief: (l, n) => `Menang ${n}x beruntun (Survival)`, hint: 'Yang dihitung rekor beruntun terbaikmu di Survival.' },
arcade: { read: s => (s.ach?.arcade_clear ? 1 : 0), brief: () => 'Tamatkan mode Arcade' },
ach: { read: s => count(s.ach, k => k !== ACH_ALL), brief: l => `Buka ${l} pencapaian lagi` },
eggs: { read: s => count(s.eggs, k => !/^(code|dialog):/.test(k)), brief: l => `Temukan ${l} kejutan lagi`, hint: 'Coba ketik, ketuk, atau klik sesuatu yang tidak biasa.' },
pairs: { read: s => count(s.eggs, k => k.startsWith('dialog:')), brief: l => `Temukan ${l} dialog rahasia lagi`, hint: 'Pertemukan pasangan lawan tertentu di mode Versus.', avail: () => Object.keys(window.DIALOGS?.rahasia || {}).length > 0 },
shop: { read: s => Math.max(0, len(s.owned?.skin) - 1) + len(s.owned?.move) + len(s.owned?.alt), brief: l => `Beli ${l} barang toko lagi`, hint: 'Skin, jurus toko, atau varian skill.' },
coins: { read: s => num(s.earned), brief: l => `Kumpulkan ${l} koin lagi` }
};
const need = st => Math.max(1, Math.floor(num(st.need.n)));
const hex = c => (/^#[0-9a-f]{6}$/i.test(c || '') ? c : FALLBACK_COLOR);
// Keadaan satu slot: stage = jumlah petunjuk terbuka (berurutan), next = syarat petunjuk berikutnya (null bila semua terbuka).
function compute(i, s) {
const d = window.MYSTERY[i];
if (!d || !Array.isArray(d.stages)) return null;
const stages = d.stages.filter(st => st && st.need && GOALS[st.need.type] && (!GOALS[st.need.type].avail || GOALS[st.need.type].avail()));
if (!stages.length) return null;
let open = 0;
while (open < stages.length && GOALS[stages[open].need.type].read(s) >= need(stages[open])) open++;
if (PREVIEW !== null) open = Math.min(PREVIEW, stages.length);
const nx = stages[open], g = nx && GOALS[nx.need.type], n = nx ? need(nx) : 0, cur = nx ? Math.min(n, g.read(s)) : 0;
return { i, id: String(d.id || 'm' + i), color: hex(d.color), shape: d.shape, max: stages.length, stage: open, clues: stages.slice(0, open),
next: nx ? { n, cur, brief: g.brief(n - cur, n, cur), hint: g.hint || '', pct: Math.round(cur / n * 100) } : null };
}
// Petak yang punya data saja; locked = jumlah petak terkunci di roster (menu.js) supaya "N dari M" sesuai yang tampil.
function stateOf(id, locked) {
const i = slotIndex(id), cap = Math.min(locked == null ? Infinity : locked, enabled() ? window.MYSTERY.length : 0);
if (i < 0 || i >= cap) return null;
const st = compute(i, save());
if (st) { st.n = i + 1; st.total = cap; }
return st;
}
function states(locked) {
const cap = Math.min(locked == null ? Infinity : locked, enabled() ? window.MYSTERY.length : 0), s = save();
return Array.from({ length: cap }, (_, i) => { const st = compute(i, s); if (st) { st.n = i + 1; st.total = cap; } return st; });
}
// ---- siluet: tubuh dari locked.svg + satu benda per slot (muncul samar di tahap kedua terakhir, jelas di tahap akhir) ----
const BODY = 'm50 58 9-27 18-12 20 8 17 24-6 7 2 24-12 15-1 13 28 12 12 13 13 45H10l13-45 12-13 28-12-1-13-12-15-2-24Z';
const RING = 'fill="none" stroke="currentColor" stroke-width="3.2"';
const SHAPES = {
palu: '<path d="M114.6 107.5 109.4 104.5 135.4 58.5 140.6 61.5Z"/><path d="M154 58 146 72 122 58 130 44Z"/><circle cx="111" cy="107" r="7"/>',
gunting: `<path d="M126.8 105 121.2 103 147 33.7 149 34.3Z"/><path d="M145.2 105 150.8 103 125 33.7 123 34.3Z"/><circle cx="120" cy="111" r="6.5" ${RING}/><circle cx="152" cy="111" r="6.5" ${RING}/>`,
pengeras: '<path d="M112 68 154 44 154 100 112 80Z"/><path d="M104 69 112 68 112 80 104 79Z"/><path d="M122 86 132 90 128 106 119 103Z"/>',
helm: '<path d="M42 48A39 33 0 0 1 120 48Z"/><path d="M34 48H128L126 56H36Z"/><path d="M77 14H85V30H77Z"/>',
ponsel: '<rect x="118" y="46" width="30" height="54" rx="5"/><path d="M124 38Q133 30 142 38M118 32Q133 20 148 32" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>'
};
const VARIANTS = [[1, 1], [-1, .95], [1, 1], [-1, 1.04], [1, .93]]; // [arah, lebar] supaya tiap slot punya potongan tubuh berbeda
const GLYPHS = ['?', '??', '?!', '!?', '!'];
const glyph = st => GLYPHS[st.stage >= st.max ? 4 : Math.min(3, Math.round(st.stage / st.max * 4))];
function sil(st) {
const [dir, w] = VARIANTS[st.i % VARIANTS.length], cls = st.stage >= st.max ? 'ms-f' : st.stage === st.max - 1 ? 'ms-g' : '';
return `<svg class="ms-svg ${cls}" viewBox="0 0 160 180" preserveAspectRatio="xMidYMax meet" aria-hidden="true" focusable="false"><g transform="translate(80 0) scale(${(dir * w).toFixed(2)} 1) translate(-80 0)"><path class="ms-body" d="${BODY}"/><g class="ms-acc">${SHAPES[st.shape] || ''}</g></g></svg>`;
}
// --ms-p (0..1) = tahap/jumlah tahap: CSS memakainya untuk blur, kekuatan garis tepi, dan kecerahan tanda tanya.
const varMap = st => ({ '--ms-c': st.color, '--ms-a': st.color + '44', '--ms-b': st.color + '00', '--ms-p': (st.max ? st.stage / st.max : 0).toFixed(3) });
const vars = st => Object.entries(varMap(st)).map(([k, v]) => k + ':' + v).join(';');
// ---- petunjuk yang sudah dilihat (titik BARU dan toast) ----
let seenCache = null;
function seenMap() { if (!seenCache) { try { const v = JSON.parse(localStorage.getItem(SEEN_KEY) || '{}'); seenCache = v && typeof v === 'object' ? v : {}; } catch (_) { seenCache = {}; } } return seenCache; }
function seenSave() { try { localStorage.setItem(SEEN_KEY, JSON.stringify(seenCache || {})); } catch (_) {} }
// s = tahap yang sudah dilihat di panel; t = tahap yang sudah diberitahukan lewat toast. Keduanya turun ikut bila simpanan di-reset.
function see(st) { if (PREVIEW !== null) return; const m = seenMap(), e = m[st.id]; if (e && e.s === st.stage && e.t === st.stage) return; m[st.id] = { s: st.stage, t: st.stage }; seenSave(); }
function announce(list) {
if (PREVIEW !== null) return;
const m = seenMap(); let fresh = 0, changed = false;
for (const st of list) {
if (!st) continue;
const e = m[st.id] || (m[st.id] = { s: 0, t: 0 });
if (st.stage > e.t) fresh += st.stage - e.t;
if (e.t !== st.stage) { e.t = st.stage; changed = true; }
}
if (changed) seenSave();
if (fresh) window.Meta?.toast?.(fresh === 1 ? 'Petunjuk baru terbuka di slot misteri!' : fresh + ' petunjuk baru terbuka di slot misteri!');
}
function evaluate(list) {
if (PREVIEW !== null) return;
const live = list.filter(Boolean);
if (live.length && live.every(st => st.stage >= st.max)) window.Meta?.unlock?.(ACH_ALL);
}
function overview(locked) {
const live = states(locked).filter(Boolean);
if (!live.length) return '';
return `${live.length} slot misteri · ${live.reduce((a, st) => a + st.stage, 0)} dari ${live.reduce((a, st) => a + st.max, 0)} petunjuk terbuka`;
}
// ---- tampilan ----
// Petak roster: img tetap di alur (hanya disembunyikan lewat CSS) sehingga ukuran grid tidak berubah.
function decorateTile(tile, st, fresh) {
const lv = st.stage, done = lv >= st.max;
tile.classList.add('ms-tile'); tile.classList.toggle('ms-on', lv > 0); tile.classList.toggle('ms-done', done);
for (const [k, v] of Object.entries(varMap(st))) tile.style.setProperty(k, v);
tile.setAttribute('aria-label', `Slot misteri ${st.n} dari ${st.total}, petunjuk terbuka ${lv} dari ${st.max}, terkunci`);
const badge = tile.querySelector(':scope > b'), label = tile.querySelector(':scope > span');
if (badge) badge.textContent = lv + '/' + st.max;
if (label) label.textContent = done ? 'LENGKAP' : lv ? 'MISTERI' : 'LOCKED';
tile.querySelectorAll('.ms-sil,.ms-q,.ms-new').forEach(e => e.remove());
if (lv) tile.querySelector('img')?.insertAdjacentHTML('afterend', `<i class="ms-sil" aria-hidden="true">${sil(st)}</i><i class="ms-q" aria-hidden="true">${glyph(st)}</i>`);
if (fresh) tile.insertAdjacentHTML('beforeend', '<i class="ms-new" aria-hidden="true"></i>');
}
// Panel pratinjau slot yang disorot (menggantikan fighterPanel untuk petak terkunci); '' bila id bukan slot misteri.
function panel(id, side, locked) {
try {
const st = stateOf(id, locked); if (!st) return '';
const art = st.stage ? `${sil(st)}<b class="ms-glyph" aria-hidden="true">${glyph(st)}</b>` : '<img src="assets/menu/locked.svg" alt="">';
const dots = Array.from({ length: st.max }, (_, k) => `<i${k < st.stage ? ' class="on"' : ''}></i>`).join('');
const clues = st.clues.length ? st.clues.map(c => `<li><b>${esc(c.tag || 'PETUNJUK')}</b>${esc(c.text)}</li>`).join('') : '<li class="ms-empty">Belum ada petunjuk. Siapa dia?</li>';
const nx = st.next;
const next = nx ? `<small>PETUNJUK BERIKUTNYA</small><strong>${esc(nx.brief)}</strong>${nx.hint ? `<em>${esc(nx.hint)}</em>` : ''}<span class="ms-prog"><i><u style="width:${nx.pct}%"></u></i><b>${nx.cur}/${nx.n}</b></span>` : '<small>SEMUA PETUNJUK TERBUKA</small><strong>Tinggal menunggu giliran tampil.</strong>';
return `<div class="fighter-plinth ${side} mystery" data-ms-lv="${st.stage}" style="${vars(st)}"><div class="plinth-light"></div><span class="side-tag">${side === 'player' ? 'P1 / YOUR FIGHTER' : 'CPU / YOUR RIVAL'}</span><div class="ms-art">${art}</div><div class="ms-card"><div class="ms-head"><span>SLOT MISTERI ${st.n} / ${st.total}</span><span class="ms-dots" role="img" aria-label="Petunjuk terbuka ${st.stage} dari ${st.max}">${dots}</span></div><h2 class="ms-title">???</h2><ol class="ms-clues">${clues}</ol><div class="ms-next">${next}</div></div></div>`;
} catch (e) { console.warn('[mystery]', e); return ''; }
}
// Dipanggil menu.js tiap selesai render layar pilih karakter.
function decorate(root, screen, hover, locked) {
if (screen !== 'select' || !enabled()) return;
try {
const list = states(locked), seen = seenMap();
root.querySelectorAll('.roster-tile.locked[data-fighter^="locked-"]').forEach(tile => {
const st = list[slotIndex(tile.dataset.fighter)];
if (st) decorateTile(tile, st, PREVIEW === null && st.stage > (seen[st.id]?.s || 0));
});
announce(list); evaluate(list); preview(root, hover, locked);
} catch (e) { console.warn('[mystery]', e); }
}
// Dipanggil menu.js saat sorotan berpindah: teks #fighter-moves dan #roster-notice; slot yang disorot dianggap sudah dilihat.
function preview(root, hover, locked) {
if (!enabled()) return;
try {
const st = stateOf(hover, locked), moves = root.querySelector('#fighter-moves'), note = root.querySelector('#roster-notice');
if (moves) {
moves.classList.toggle('ms-moves', !!st);
if (st) moves.innerHTML = `<span>Slot misteri ${st.n} / ${st.total} · petunjuk ${st.stage} / ${st.max}</span><b>${esc(st.next ? st.next.brief : 'Semua petunjuk terbuka')}</b>`;
}
if (st) { see(st); root.querySelector(`[data-fighter="${hover}"] .ms-new`)?.remove(); }
if (note) note.textContent = st ? `Slot misteri ${st.n}/${st.total} · ${st.next ? st.next.brief : 'petunjuk lengkap'}` : overview(locked);
} catch (e) { console.warn('[mystery]', e); }
}
// Pesan saat slot terkunci ditekan/dikonfirmasi; '' bila bukan slot misteri (menu.js memakai pesan lamanya).
function notice(id, locked) {
try { const st = stateOf(id, locked); return st ? `Slot misteri ${st.n}/${st.total} · ${st.next ? st.next.brief : 'petunjuk lengkap'}` : ''; } catch (_) { return ''; }
}
window.Mystery = {
panel, decorate, preview, notice,
overview: locked => { try { return enabled() ? overview(locked) : ''; } catch (_) { return ''; } },
// Ringkasan untuk uji manual di konsol: Mystery.states()
states: locked => { try { return states(locked).filter(Boolean).map(st => ({ slot: st.n, id: st.id, stage: st.stage, max: st.max, next: st.next && st.next.brief })); } catch (_) { return []; } }
};
})();