diff --git a/README.md b/README.md index bdc1093..7caf220 100644 --- a/README.md +++ b/README.md @@ -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 ├── feel.css # peringkat kombo ├── 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 ├── menu.js # menu: alur layar, roster, arena, tingkat kesulitan, kolase latar ├── match.js # aturan ronde, timer, arena, penyetelan tingkat CPU ├── feel.js # rasa kena: hit-stop, getaran, K.O. gerak lambat ├── 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" -├── 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 ├── touch.js # kontrol sentuh di layar ├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets//manifest.js) diff --git a/content/mystery.js b/content/mystery.js new file mode 100644 index 0000000..eb98912 --- /dev/null +++ b/content/mystery.js @@ -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.' } + ] + } +]; diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 80616b6..044e4a3 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -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']`. - 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`. +- 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)`. - `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/-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). diff --git a/index.html b/index.html index 885dcac..ad9e67f 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,7 @@ Konoha Fighting - +
Menyiapkan…

@@ -24,6 +24,6 @@
✦KONOHA FIGHTING

Menyiapkan arena…
KONOHA FIGHTING / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat (2× W = lompat ganda)   S · Menunduk
SPACE · Kombo 3 pukulan   ↓ ↓ + SPACE · Uppercut   ↓ → + SPACE · Tinju maju
I / O · Skill   P · Ultimate   K · Jurus toko
L · Blok (ketuk = parry)   J · Lempar
Esc · Jeda, berisi daftar gerakan karakter   R · Mulai ulang
Dua pemain (P2): ← → · Gerak   ↑ · Lompat   N · Serang   M / , / . · Skill 1 / 2 / Ultimate
Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.

KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
-
+
diff --git a/menu.js b/menu.js index 697c68e..4951584 100644 --- a/menu.js +++ b/menu.js @@ -25,6 +25,8 @@ // 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 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. const PICK_FLASH=.3;let lockIn=null,lockTicks=[]; const ids=Object.keys(fighters); @@ -53,7 +55,7 @@ const brand='KONOHAFIGHTING✦',versionText=()=>window.Brand?.version?'VERSI '+window.Brand.version:''; const footer=()=>'
W A S D / ↑ ↓ ← → SELECT   ENTER CONFIRM   ESC BACKLOCAL PLAY '+(versionText()||'BUILD 02')+'
'; function navHeader(title,subtitle){return `
${subtitle}

${title}

${brand}
`;} - function fighterPanel(id,side){const f=info(id);return `
${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}${f?.name||'Karakter belum dipilih'}
${f?.tag||'AWAITING CHALLENGER'}

${f?.name||'???'}

${f?.race||'ROSTER EXPANDING'}

${f?.detail||'A new challenger is on the way.'}
`;} + function fighterPanel(id,side){const ms=window.Mystery?.panel(id,side,lockedSlots);if(ms)return ms;const f=info(id);return `
${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}${f?.name||'Karakter belum dipilih'}
${f?.tag||'AWAITING CHALLENGER'}

${f?.name||'???'}

${f?.race||'ROSTER EXPANDING'}

${f?.detail||'A new challenger is on the way.'}
`;} function render(){ 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; @@ -72,16 +74,16 @@ } else if(screen==='result'){ const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`
${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}
${fighters[winner].name}
${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH

${won?'VICTORY':'DEFEAT'}

${fighters[winner].name} WINS

${result.playerWins}—${result.enemyWins}
${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}
`; } - 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){ 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 f=info(hover),moves=root.querySelector('#fighter-moves');if(moves)moves.innerHTML=f?`${f.style}${soon(hover)?'':`${f.ultimate}`}`:'NEW CHALLENGERCOMING LATER'; - 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 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. 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'); diff --git a/meta.js b/meta.js index d3e849b..79059b2 100644 --- a/meta.js +++ b/meta.js @@ -53,6 +53,7 @@ mbg: ['TRUK LEWAT', 'Panggil truk katering.'], esemka: ['KONVOI', 'Panggil konvoi.'], penasaran: ['PENASARAN', 'Ketuk slot tanda tanya berulang kali.'], + penasaran_berat: ['PENASARAN BERAT', 'Buka semua petunjuk slot misteri.'], logo: ['TELITI', 'Temukan rahasia di judul.'], parry: ['TEPAT WAKTU', 'Lakukan parry yang berhasil.'], arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'], @@ -271,7 +272,7 @@ } function renderBook() { 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 `
  • ${ok ? '★' : '☆'} ${hidden ? '???' : n}${hidden ? 'Pencapaian rahasia.' : d}
  • `; }).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 `
  • ${ok ? '★' : '☆'} ${hidden ? '???' : n}${hidden ? 'Pencapaian rahasia.' : d}
  • `; }).join(''); return `

    Statistik

    Menang ${st.wins} · Kalah ${st.losses} · Bersih ${st.perfects} · Kombo terbaik ${st.bestCombo} · Total koin ${save.earned}

    Pencapaian ${got}/${Object.keys(ACH).length}

    Suara

    Memakai suara bawaan browser.

    Simpanan

    `; } function onClick(e) { diff --git a/mystery.css b/mystery.css new file mode 100644 index 0000000..e501aaa --- /dev/null +++ b/mystery.css @@ -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} +} diff --git a/mystery.js b/mystery.js new file mode 100644 index 0000000..1c00e30 --- /dev/null +++ b/mystery.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: '', + gunting: ``, + pengeras: '', + helm: '', + ponsel: '' + }; + 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 ``; + } + // --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', ``); + if (fresh) tile.insertAdjacentHTML('beforeend', ''); + } + // 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)}` : ''; + const dots = Array.from({ length: st.max }, (_, k) => ``).join(''); + const clues = st.clues.length ? st.clues.map(c => `
  • ${esc(c.tag || 'PETUNJUK')}${esc(c.text)}
  • `).join('') : '
  • Belum ada petunjuk. Siapa dia?
  • '; + const nx = st.next; + const next = nx ? `PETUNJUK BERIKUTNYA${esc(nx.brief)}${nx.hint ? `${esc(nx.hint)}` : ''}${nx.cur}/${nx.n}` : 'SEMUA PETUNJUK TERBUKATinggal menunggu giliran tampil.'; + return `
    ${side === 'player' ? 'P1 / YOUR FIGHTER' : 'CPU / YOUR RIVAL'}
    ${art}
    SLOT MISTERI ${st.n} / ${st.total}${dots}

    ???

      ${clues}
    ${next}
    `; + } 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 = `Slot misteri ${st.n} / ${st.total} · petunjuk ${st.stage} / ${st.max}${esc(st.next ? st.next.brief : 'Semua petunjuk terbuka')}`; + } + 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 []; } } + }; +})();