Gabung wf/t3-story: mode cerita Arcade dan bos rahasia Bayangan (RENCANA 3.5 dan 3.6)

Konflik game.js di startMatch diselesaikan dengan mempertahankan kedua sisi: refreshPassives(matchStyles(...)) dari pasif dijalankan dulu, lalu setShadowFoe(...), dan info layar VS membawa styles sekaligus shadow. Urutan pengali di dotDamage dan receiveHit: pengali pasif dulu, lalu +10% Bayangan.
This commit is contained in:
rizkyaz0 2026-10-04 07:38:00 +07:00
commit 0cec0c0ac0
8 changed files with 552 additions and 49 deletions

163
content/story.js Normal file
View File

@ -0,0 +1,163 @@
/* Cerita Arcade (RENCANA 3.5 dan 3.6): window.STORY. Dibaca story.js (kartu cerita) dan meta.js (alur Arcade).
Pedoman isi: lucu dan ringan, plesetan citra publik yang sudah jadi bahan lelucon umum. Tanpa tuduhan fakta atau skandal nyata,
tanpa unsur agama, tanpa merendahkan pribadi atau keluarga. Gufron hanya meme ringan (semut dan 'syududu').
Bentuk (kunci = id karakter pemain):
- intro[id] 2 sampai 3 kalimat sebelum lawan pertama; title.intro[id] = judul kartu (opsional).
- between[A][B] 1 sampai 2 kalimat singkat saat pemain A menuju lawan B (boleh diganti fungsi (A, B, urutan, jumlah) => teks); dibaca dalam sekitar 4 detik.
- ending[id] 3 kalimat penutup; boss.end menambah satu kalimat sesuai hasil lawan Bayangan; title.ending[id] = judul kartu (opsional).
- boss bos rahasia BAYANGAN (salinan gelap karakter pemain): lead = pembuka bersama, intro[id] = pembuka khas karakter, vs[id] = ucapan di layar VS,
win[id] = ucapan Bayangan saat kalah, lose[id] = ucapan Bayangan saat menang, end.win[id] / end.lose[id] = kalimat tambahan akhir cerita, title = gelar.
- generic cadangan bila sebuah karakter belum punya data (mis. karakter baru). Penampung teks: {name} nama pemain, {rival} nama lawan, {n} jumlah lawan (kata). */
window.STORY = {
intro: {
browo: ['Arena Konoha pagi ini ramai, dan Bro Wo mengira ini hanya safari kecil.', 'Topinya sudah rapi, sepatu botnya sudah kencang, dan di dalam tas ada sebibit pohon sawit untuk jaga-jaga.', 'Di balik gerbang, {n} lawan menunggu; Bro Wo menggoyang pinggul sekali, lalu melangkah masuk.'],
gufron: ['Gufron berjongkok di pinggir arena, berbisik pada barisan semut yang lewat.', 'Kata semut-semut itu: lawannya banyak, remahnya sedikit, tetapi koloni tidak pernah takut.', 'Gufron mengangguk, berdiri, lalu berbisik pada seluruh koloni: "Syududu."'],
soloman: ['Solo Man tiba di Arena Konoha dengan cara paling sederhana: jalan kaki, sarung disampirkan, senyum tipis.', 'Konvoi Esemka-nya menunggu di parkiran, mesinnya sudah dipanasi untuk jaga-jaga.', 'Ia mengangguk ke panitia, lalu blusukan ke arena pertama.'],
anice: ['A Nice naik ke mimbar arena dan merapikan kertas yang sama sekali tidak ia butuhkan.', 'Pidatonya sudah disusun dalam tiga bagian: pembuka, isi, dan satu pukulan sebagai penutup.', 'Ia berdeham, {n} lawan menunggu, dan turnamen pun dimulai tepat di akhir kalimat pertama.'],
ethanolman: ['Ethanol Man datang serba putih, tangki penuh, dengan tawa yang sudah terdengar dari parkiran.', 'Semuanya sudah ia hitung di kepala, kecuali bagian ketika angka-angkanya berubah.', '"Hahaha!" katanya. Turnamen ini akan jadi swasembada tawa.'],
sonofsolo: ['Son of Solo Man masuk arena dengan tatapan setenang kolam dan sebuah labu kecil di tangan.', 'Katanya isinya bukan air mineral, dan panitia memilih tidak bertanya lebih jauh.', 'Ia punya satu pertanyaan untuk setiap lawan, dan semuanya pertanyaan jebakan.'],
megachan: ['Mega Chan melangkah ke tengah arena berblus merah, kepalan terangkat, orasinya sudah dilatih sejak pagi buta.', 'Di belakangnya, sekawanan banteng menunggu aba-aba sambil menggaruk tanah.', '"Siapa mau duluan?" tanyanya, dan {n} lawan mendadak sibuk memeriksa sepatu.']
},
title: {
intro: { browo: 'SAFARI DIMULAI', gufron: 'BARISAN SEMUT', soloman: 'JALAN SEDERHANA', anice: 'PIDATO PEMBUKA', ethanolman: 'TANGKI PENUH', sonofsolo: 'PERTANYAAN JEBAKAN', megachan: 'KEPALAN MERAH' },
ending: { browo: 'JOGET TERAKHIR', gufron: 'PESTA REMAH', soloman: 'KONVOI PULANG', anice: 'PIDATO PENUTUP', ethanolman: 'SWASEMBADA TAWA', sonofsolo: 'PERTANYAAN BARU', megachan: 'MERAH MENYALA' }
},
between: {
browo: {
gufron: 'Di gerbang berikutnya, semut-semut Gufron sudah berbaris rapi. Bro Wo memutuskan berjoget dulu supaya mereka tidak gugup.',
soloman: 'Solo Man menunggu dengan sarung rapi dan Esemka yang sudah dipanasi. Bro Wo menyapa dengan joget, dibalas anggukan sederhana.',
anice: 'A Nice sudah di mimbar, membuka pidato tiga bagian. Bro Wo membalas dengan satu joget, dan skornya langsung imbang.',
ethanolman: 'Ethanol Man tertawa dari kejauhan, tangkinya penuh. Bro Wo ikut tertawa, walau belum tahu apa yang lucu.',
sonofsolo: 'Son of Solo Man menatap tenang sambil memutar labu kecil. Bro Wo bertanya apa isinya, dan hanya dijawab senyum tipis.',
megachan: 'Bro Wo menghitung pohon sawitnya, Mega Chan menghitung bantengnya. Keduanya pura-pura tidak khawatir.'
},
gufron: {
browo: 'Bro Wo berjoget di tengah arena. Semut-semut ikut mengangguk mengikuti irama; kata mereka, jogetnya lumayan.',
soloman: 'Solo Man datang dengan sarung dan senyum tenang. Semut-semut Gufron langsung membuat barisan konvoi mini.',
anice: 'A Nice berpidato panjang dari mimbar. Semut-semut Gufron mencatatnya sampai remah terakhir.',
ethanolman: 'Ethanol Man tertawa keras dan tangkinya bergemericik. Semut-semut mundur selangkah: bau etanol bukan bau remah.',
sonofsolo: 'Son of Solo Man menatap tenang dengan labu di tangan. Gufron berbisik pada semutnya: jangan minum apa pun dari situ.',
megachan: 'Mega Chan melepas banteng-bantengnya. Semut-semut Gufron berbaris di pinggir, penonton paling sopan hari ini.'
},
soloman: {
browo: 'Bro Wo bergoyang di depan pohon sawitnya. Solo Man hanya mengangguk, sederhana saja: kita lihat nanti.',
gufron: 'Gufron berjongkok bicara pada semut. Solo Man ikut berjongkok sebentar, blusukan sampai ke tingkat remah.',
anice: 'A Nice sudah siap dengan pidato tiga bagian. Solo Man menjawab sederhana: "Silakan, saya dengarkan sambil jalan."',
ethanolman: 'Ethanol Man tertawa dengan tangki penuh. Solo Man menengok Esemka-nya: bahan bakarnya cukup untuk satu konvoi.',
sonofsolo: 'Son of Solo Man menatap tenang sambil menggoyang labu. Solo Man mengibas sarung dan menjawab: "Tanya satu-satu saja."',
megachan: 'Mega Chan berdiri dengan kepalan terangkat dan banteng di belakangnya. Solo Man menyalakan Esemka, sekadar jaga-jaga.'
},
anice: {
browo: 'Bro Wo menyambut dengan joget. A Nice mencatat: gerakannya tiga bagian, yaitu pembuka, isi, dan goyang penutup.',
gufron: 'Gufron berbisik pada semut-semutnya. A Nice menunggu dengan sabar; ia sudah menyiapkan narasi tentang remah.',
soloman: 'Solo Man hanya tersenyum sederhana. A Nice sempat bingung: pidatonya tiga bagian, lawannya cuma satu kalimat.',
ethanolman: 'Tawa Ethanol Man menenggelamkan mikrofon. A Nice mengulang paragraf pertama dengan sabar, sekali lagi.',
sonofsolo: 'Son of Solo Man bertanya dengan tatapan tenang. A Nice menjawab dengan tiga paragraf, dan ia belum selesai.',
megachan: 'Mega Chan membuka dengan orasi lantang. A Nice menegakkan mimbarnya: dua pidato, satu arena, satu pemenang.'
},
ethanolman: {
browo: 'Bro Wo bergoyang di antara pohon sawitnya. Ethanol Man tertawa: "Hahaha, sesama bahan bakar masa depan!"',
gufron: 'Semut-semut Gufron berbaris mengelilingi tangki. Ethanol Man tertawa: "Hahaha, itu bukan gula!"',
soloman: 'Solo Man datang naik Esemka. Ethanol Man tertawa dan langsung menawarkan bahan bakar hijau.',
anice: 'A Nice berpidato dari mimbar. Ethanol Man tertawa di barisan depan, tepat di bagian yang tidak lucu.',
sonofsolo: 'Dua ahli cairan saling menatap: labu melawan tangki. Ethanol Man tertawa duluan, sekadar memecah suasana.',
megachan: 'Mega Chan melepas banteng-bantengnya. Ethanol Man tertawa, tangkinya penuh, dan semangatnya lebih penuh lagi.'
},
sonofsolo: {
browo: 'Bro Wo berjoget gemoy di depan arena. Son of Solo Man memandangnya tenang dan hanya bertanya: "Itu sawit asli?"',
gufron: 'Gufron berbisik pada semut. Son of Solo Man mengangkat tangan: "Boleh tanya, ada yang mau menjawab soal jebakan?"',
soloman: 'Solo Man mengibas sarung dengan sederhana. Son of Solo Man memutar labu; tatapannya tenang, pertanyaannya belum keluar.',
anice: 'A Nice menyusun pidato tiga bagian. Son of Solo Man hanya mengangkat tangan: "Boleh bertanya di sela-sela?"',
ethanolman: 'Ethanol Man tertawa sambil menimang tangki. Son of Solo Man tetap tenang dan memeriksa labunya sekali lagi.',
megachan: 'Mega Chan mengangkat kepalan dan memanggil banteng. Son of Solo Man bertanya, tenang: "Merah itu warna apa?"'
},
megachan: {
browo: 'Bro Wo berjoget di tengah arena. Mega Chan menaikkan kepalan: joget boleh, tapi banteng tetap lewat.',
gufron: 'Semut-semut Gufron berbaris rapi. Banteng Mega Chan melangkah hati-hati; jangan sampai ada yang terinjak.',
soloman: 'Solo Man datang dengan sarung dan konvoi Esemka. Mega Chan mengangkat kepalan: satu arena, dua rombongan.',
anice: 'A Nice sudah di mimbar dengan pidato tiga bagian. Mega Chan membalas dengan orasi lantang: dua mimbar sekaligus.',
ethanolman: 'Ethanol Man tertawa dengan tangki penuh. Mega Chan hanya mengangkat alis: banteng tidak takut pada tangki.',
sonofsolo: 'Son of Solo Man menatap tenang dengan labu di tangan. Mega Chan balas menatap: tatapan banteng lebih dalam.'
}
},
ending: {
browo: ['Debu arena mengendap, dan Bro Wo berdiri di tengahnya dengan topi yang masih rapi.', 'Panitia menyodorkan piala, tetapi ia menukarnya dengan satu joget panjang, dan seluruh arena ikut bergoyang.', 'Bibit pohon sawit dari tasnya tertanam persis di titik kemenangan, dan konon masih tumbuh sampai sekarang.'],
gufron: ['Arena sepi, tetapi tanah di bawah kaki Gufron bergerak pelan: ribuan semut pulang berbaris sambil membawa remah kemenangan.', 'Gufron berjongkok dan berterima kasih dalam bahasa semut, yang terdengar persis seperti "syududu".', 'Panitia hendak menyerahkan piala, tetapi semut-semut sudah mengangkatnya lebih dulu ke sarang.'],
soloman: ['Arena kembali tenang, dan Solo Man berjalan keluar dengan cara paling sederhana: sarung dikibas, senyum tipis.', 'Konvoi Esemka menunggu di gerbang, mesinnya masih hangat sejak pagi.', 'Ditanya soal kemenangannya, ia hanya menjawab, "Kerja, kerja, kerja. Sudah, itu saja."'],
anice: ['Tepuk tangan memenuhi arena, dan A Nice naik ke mimbar untuk pidato kemenangan yang sudah ia siapkan sejak awal.', 'Pidatonya tiga bagian: pembuka, isi, dan penutup yang kali ini berupa satu pukulan.', 'Penonton bertepuk tangan, dan sebagian masih menunggu paragraf keempat.'],
ethanolman: ['Tangki Ethanol Man masih penuh, dan tawanya masih lebih keras daripada sorak penonton.', 'Panitia menyerahkan hadiah berupa satu drum bioetanol, yang langsung ia sebut "swasembada tawa".', '"Hahaha! Hari ini arena, besok seluruh jalan raya!"'],
sonofsolo: ['Arena senyap. Son of Solo Man menatap tenang ke atas dan mengangkat labunya, yang kini sudah kosong.', 'Ia tidak merayakan apa-apa, hanya mencatat satu pertanyaan baru di buku kecilnya.', 'Pertanyaannya: kenapa semua orang di arena mendadak mundur tiga langkah?'],
megachan: ['Sorak merah memenuhi arena, dan Mega Chan mengangkat kepalannya ke langit.', 'Banteng-banteng mengangguk bangga, lalu pulang dengan langkah yang sangat teratur.', 'Orasi kemenangannya cuma satu kalimat, yang katanya sengaja dipendekkan: "Merah tidak pernah padam!"']
},
boss: {
title: 'PENAKLUK BAYANGAN',
lead: ['Lampu arena meredup, dan bulan terbit di atas Moonrise Bastion.'],
intro: {
browo: ['Dari bayangan pohon sawit di sudut, muncul sosok bertopi yang menirukan joget Bro Wo, hanya saja jauh lebih gelap.', 'Bro Wo membetulkan topinya: "Wah, ada yang meniru."'],
gufron: ['Semut-semut mendadak berhenti berbaris, sebab di ujung arena ada sosok gelap yang berjongkok dan membisiki semutnya dengan bisikan yang sama persis.', 'Gufron menoleh pelan: "Syududu...?"'],
soloman: ['Konvoi Esemka tiba-tiba mogok serentak, lalu dari balik asap knalpot muncul sosok bersarung yang tersenyum sederhana, dengan bayangan lebih panjang daripada orangnya.', 'Solo Man mengangguk: "Kita bicarakan sambil jalan."'],
anice: ['Mimbar arena berderit sendiri, dan seseorang di baliknya mulai berpidato dengan kalimat yang persis sama: tiga bagian, lengkap dengan jeda.', 'A Nice menaikkan alis: "Izin, itu naskah saya."'],
ethanolman: ['Tawa Ethanol Man menggema dua kali: satu dari dirinya, satu dari kegelapan, tempat berdiri sosok serba hitam dengan tangki menyala ungu.', 'Untuk pertama kalinya hari ini, Ethanol Man berhenti tertawa.'],
sonofsolo: ['Labu di tangan Son of Solo Man bergetar pelan, sebab di seberang arena ada tatapan yang sama tenangnya dengan labu yang sama persis, hanya saja berwarna ungu.', '"Pertanyaannya," gumamnya, "siapa yang bertanya pada siapa?"'],
megachan: ['Banteng-banteng mendadak berhenti dan menunduk, sebab di tengah arena ada sosok berblus gelap yang mengangkat kepalan yang sama persis.', 'Mega Chan menaikkan kepalannya: "Satu panggung, dua orasi? Tidak bisa."']
},
vs: {
browo: 'Jogetmu bagus. Jogetku lebih gelap.',
gufron: 'Semutku juga bisa berbisik. Syududu.',
soloman: 'Sederhana saja: kamu melawan dirimu sendiri.',
anice: 'Pidatomu kupinjam. Pukulan penutupnya kubuat sendiri.',
ethanolman: 'Tangkiku juga penuh. Tawanya saja yang lebih gelap.',
sonofsolo: 'Labuku berisi pertanyaan yang tak berani kamu jawab.',
megachan: 'Banteng hitam tidak mengenal kata mundur.'
},
win: {
browo: 'Jogetmu... tetap lebih terang.',
gufron: 'Semutmu menang. Remahnya dibagi dua, ya.',
soloman: 'Ternyata kamu memang sederhana. Hebat.',
anice: 'Naskahmu... lebih rapi daripada punyaku.',
ethanolman: 'Hahaha... ternyata yang asli lebih kencang.',
sonofsolo: 'Satu pertanyaan lagi... siapa yang tadi bertanya?',
megachan: 'Banteng hitam pun pulang. Merah menang.'
},
lose: {
browo: 'Bayangan juga bisa berjoget, lho.',
gufron: 'Semutmu salah barisan. Syududu.',
soloman: 'Kita evaluasi... lain kali, ya.',
anice: 'Narasi selesai. Titik.',
ethanolman: 'Hahaha! Tangki hitam menang!',
sonofsolo: 'Pertanyaan terjawab. Kamu kalah.',
megachan: 'Banteng hitam tidak pernah salah arah.'
},
end: {
win: {
browo: 'Di bawah bulan, bayangan ungu itu menghilang sambil, konon, ikut berjoget.',
gufron: 'Sosok gelap itu memudar, meninggalkan satu semut ungu yang melambai pelan.',
soloman: 'Bayangannya sendiri akhirnya ikut pulang, memanjang di belakang konvoi.',
anice: 'Naskah milik bayangan itu jatuh ke lantai, dan ternyata hanya judulnya yang sudah ditulis.',
ethanolman: 'Bayangan itu tertawa sekali lagi, kali ini persis seperti tawa Ethanol Man, lalu lenyap.',
sonofsolo: 'Bayangan itu lenyap bersama labu ungunya; satu pertanyaan masih menggantung: siapa yang tadi bertanya?',
megachan: 'Banteng hitam menunduk hormat dan menghilang ke dalam bayangan bulan.'
},
lose: {
browo: 'Bayangan ungu itu masih berdiri di sudut arena, menggoyang pinggul dengan irama yang sama.',
gufron: 'Sosok gelap itu masih berjongkok di sudut, membisiki semutnya; mungkin sedang merencanakan pertandingan ulang.',
soloman: 'Bayangannya masih tersenyum sederhana di sudut arena; "kita evaluasi besok," kata Solo Man.',
anice: 'Bayangannya menutup pidato dengan kalimat yang sama persis; soal hak ciptanya, kata A Nice, dibahas lain waktu.',
ethanolman: 'Tawa gelap itu masih terdengar dari pojok arena, dan Ethanol Man membalasnya, tetapi lebih pelan.',
sonofsolo: 'Bayangan itu mengangkat labu ungunya dan mengajukan pertanyaan yang sama; kali ini tak seorang pun menjawab.',
megachan: 'Banteng hitam itu masih menunggu di tepi arena, dan banteng merah mengangguk: sampai jumpa lagi.'
}
}
},
generic: {
intro: ['{name} tiba di Arena Konoha dengan satu tujuan: mengalahkan semua lawan.', 'Di balik gerbang, {n} petarung sudah menunggu, dan jalan masih panjang.'],
between: ['{name} melangkah ke arena berikutnya. {rival} sudah menunggu di sana.', '{rival} melambai dari seberang arena. {name} membalas dengan anggukan.'],
ending: ['Debu arena mengendap, dan {name} berdiri paling akhir.', 'Turnamen Konoha tamat, setidaknya sampai pertandingan ulang berikutnya.', 'Panitia sibuk mencari piala yang tadi entah ke mana.'],
bossIntro: ['Dari bayangan, muncul sosok yang bergerak persis seperti {name}.', 'Kali ini lawannya adalah dirinya sendiri.'],
bossVs: ['Kamu melawan dirimu sendiri.'],
bossWin: ['Kamu... lebih kuat daripada aku.'],
bossLose: ['Bayangan tidak pernah kalah oleh cahaya yang redup.'],
bossEndWin: ['Bayangan itu memudar bersama bulan, dan arena kembali terang.'],
bossEndLose: ['Bayangan itu masih menunggu di sudut arena, siap untuk pertandingan ulang.']
}
};

View File

@ -22,6 +22,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- `vs.js` (`window.VS`, `window.ResultView`) + `vs.css` + `content/quotes.js` (`window.QUOTES`: intro/win/lose per karakter): layar VS dan layar hasil. `startMatch({..., vs: true | {label, p2}})` menampilkan layar VS 2,2 dtk (hanya mode versus; latihan tidak); selama itu `vsHold` di game.js menahan `advanceMatch` dan cue 'Round 1' keluar tepat sekali saat VS selesai atau dilewati (klik/Spasi/Enter/Esc setelah 0,38 dtk). `startMatch` tanpa `vs` berperilaku seperti dulu. `menu.js` screen `result` memakai `ResultView.view/mount` (layar lama jadi cadangan); `Meta.award` menyimpan rincian di `Meta.lastAward`; `Meta.nextFight`/`finishRun` memakai VS dan layar hasil yang sama. Pemilih kutipan `VS.quote(id, 'intro'|'win'|'lose')` memakai pengacak kosmetik terpisah. API uji: `__game.vsActive()`, `__game.vsSkip()`. Menu depan yang tersembunyi dijamin hilang oleh `#front-menu[hidden]{display:none!important}` (menu.css); aturan `display` per `data-screen` tidak boleh menimpanya. Layar hasil memfokuskan wadah `.rs` (`tabindex=-1`), bukan tombol, agar Spasi/Enter dari pertarungan tidak langsung memicu MAIN LAGI (Tab tetap menjangkau tombol). `Meta.toast` menahan toast selama `VS.active` dan memunculkannya sesudah VS selesai.
- Dialog pasangan dan easter egg berpasangan (Tahap 3.1 dan 3.4): `content/dialogs.js` (dimuat sebelum `vs.js`) mengisi `window.DIALOGS['<idA>|<idB>'] = {a, b}` untuk 21 pasangan (kunci urut alfabet; a = ucapan idA) dan `window.DIALOGS.rahasia` (empat pasangan tersembunyi, bentuk sama, bukan bagian dari 21 kunci). `VS.dialog(idPemain, idLawan)` -> `{key, secret, l, r, reply}` memetakan ucapan ke sisi kiri (pemain) dan kanan (lawan), atau `null` bila pasangan tidak punya dialog (karakter sama) sehingga kutipan umum `QUOTES` jadi cadangan. Pasangan ber-dialog menampilkan dua balon `.vs-quote.vs-bubble` (pembuka = ucapan `a` muncul dulu, pembalas = ucapan `b` menyusul lewat kelas `.re`, apa pun sisi pemainnya) dan layar VS berlangsung `VS.durationDialog` (3,3 dtk; `VS.duration` 2,2 dtk dipakai tanpa dialog); gaya di bagian "Dialog pasangan" `vs.css`. Dialog rahasia menggantikan dialog biasa setiap pasangan itu bertemu (balon keemasan `.secret`, label `.vs-secret` DIALOG RAHASIA) dan memanggil `Meta.pairEgg(kunci)`: +75 koin dan satu toast hanya sekali per pasangan (`Meta.save.eggs['dialog:<kunci>']`), pencapaian `reuni` (tampil ??? sampai terbuka, deskripsi umum tanpa menyebut pasangan) setelah semua kunci `DIALOGS.rahasia` ditemukan. Toast ikut tertahan selama VS (aturan `Meta.toast`). Berlaku di versus, 2 pemain, Arcade, Survival; latihan tidak punya layar VS. Pedoman isi sama dengan `quotes.js`; Belum ada skenario otomatis khusus dialog (diuji manual; cek cepat: `__game.startMatch({player:'soloman',enemy:'sonofsolo',stage:'harbor',level:'easy',mode:'versus',vs:true})`, lalu `Meta.save.eggs` dan `VS.dialog(idPemain, idLawan)`). Pengujian yang menunggu layar VS selesai harus menunggu `!__game.vsActive()` (bukan tidur 2,2 dtk) karena waktunya kini bergantung pada ada-tidaknya dialog.
- HUD (game.js): meter ultimate `#ult-player`/`#ult-enemy` (`updateUltMeters`; mengisi/SIAP/waktu mode dari `modeOf`, `bwx.rage`, bentuk FENR; DOM hanya ditulis saat tampilan berubah). Menu jeda `#pause-panel` dua panel; `buildMoveList()` membangun ulang Daftar gerakan tiap `setPaused(true)`, pilihan lipat diingat di `localStorage['konoha.moveList']`.
- Mode cerita Arcade dan bos Bayangan (Tahap 3.5 dan 3.6): `content/story.js` (`window.STORY`: `intro[id]` 2-3 kalimat, `between[A][B]` 1-2 kalimat (atau fungsi), `ending[id]` 3 kalimat, `boss` = pembuka, ucapan VS/kalah/menang, kalimat akhir, gelar; `generic` cadangan dengan penampung `{name}` `{rival}` `{n}`) dan `story.js` + `story.css` (`window.Story`). `Story.show({kind: intro|between|boss|ending, player, enemy, step, total, stage, boss}, onDone)` menampilkan kartu layar penuh (`#story-screen`, z-index 45, di atas layar VS 40) dan memanggil onDone tepat satu kali: klik/ketuk/Spasi/Enter/Esc setelah 0,5 dtk, atau otomatis (kartu antar-lawan sekitar 4 dtk, kartu panjang lebih lama); selama tampil semua tombol ditahan dan `Meta` menahan toast (`Story.active`). Mengembalikan false tanpa kartu bila tidak ada teks, jadi alur tetap jalan tanpa cerita. `meta.js` Arcade: `startRun` (kartu pembuka) -> `nextFight` -> menang -> `betweenFights` (kartu antar-lawan, panggung lawan berikutnya jadi latar kartu) ... lawan terakhir kalah -> `arcadeCleared` (hadiah tamat 500 + pencapaian langsung dibayar seperti dulu) -> kartu bos -> `startBoss` -> `finishRun(true, 'win'|'lose')` -> kartu akhir -> layar hasil. Survival tidak memakai cerita. `run.boss` = null | 'pending' | 'fight'. Menang atas Bayangan: +1000 koin, pencapaian BAYANGAN (+50), gelar PENAKLUK BAYANGAN (`Meta.save.title`, tampil di kartu akhir dan plat pemenang layar hasil); kalah: Arcade tetap tamat, tanpa bonus bos. Uji manual: `?arcade=N` memendekkan Arcade menjadi N lawan. Bayangan = `startMatch({player: X, enemy: X, level: 'excellent', shadow: true})` (id sama; state `hero` dan `dummy` tetap terpisah): `setShadowFoe` mengatur `BALANCE.dummyMax` (+40%), `foeDamage` menambah 10% pada damage ke pemain (pembungkus `__game.receiveHit` di akhir game.js dan `dotDamage`), `dummyHpShown` memberi skala HP dasar untuk bar dan penentu waktu habis (`MatchRules.tick`), `drawDummy` memakai `ctx.filter` gelap + lapisan ungu `drawShadowTint` + aura, percikan ungu lewat `fxr()` di `updateVisuals`. HUD: nama BAYANGAN dengan label nama asli, kelas `.shadow-foe` (story.css). VS: game.js memanggil `Story.decorateVs(info)` tepat setelah `VS.show` (nama 'BAYANGAN <NAMA>', ucapan bos); layar hasil: `ResultView.view` membaca `run.boss` (Bayangan di sisi kalah; kalah dari Bayangan tetap tampil TAMAT). `__game.shadowFoe()` melaporkan keadaan.
- `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter.
- `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang.

53
game.js
View File

@ -238,9 +238,10 @@
if(!playable().includes(player)||!playable().includes(enemy)||!Object.hasOwn(Rules.stages,stage)||!Object.hasOwn(Rules.difficulties,level)||!['versus','training'].includes(mode))return false;
if(!images['stage-'+stage])return false;
selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY;refreshPassives(matchStyles(options,mode));
setShadowFoe(!!options.shadow&&mode==='versus');
aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins();
$('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus');
if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level,styles:{...sideStyle}})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3);
if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level,styles:{...sideStyle},shadow:shadowFoe})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3);
return true;
}
// Layar VS (opsional: options.vs = true | {label,p2}; hanya mode versus, tidak ada di latihan). Mengembalikan true bila layar tampil;
@ -248,9 +249,37 @@
function vsStart(opt,info){
const tok=++vsToken;vsHold=true;
const shown=window.VS?.show({...info,...(typeof opt==='object'?opt:{})},()=>{if(tok!==vsToken||!vsHold)return;vsHold=false;announceTimer=0;emitRoundCue('round',1);});
if(shown&&info.shadow){try{window.Story?.decorateVs?.(info);}catch(e){console.error(e);}}
if(!shown)vsHold=false;return !!shown;
}
function vsAbort(){vsToken++;vsHold=false;window.VS?.cancel?.();}
// ---- Bayangan: bos rahasia Arcade (RENCANA 3.6) ----
// startMatch({..., shadow: true}) membuat lawan salinan karakter pemain (id sama; state hero dan dummy tetap terpisah, jurus dan CPU memakai data yang sama):
// HP +40%, damage ke pemain +10% (receiveHit dan dotDamage lewat foeDamage), sprite digelapkan dan diungukan (filter kanvas, atau lapisan warna bila tanpa ctx.filter; tanpa gambar baru)
// dengan aura serta percikan ungu. Bar HP dan penentu waktu habis memakai skala HP dasar (dummyHpShown) supaya +40% tidak menggeser tampilan atau hasil.
// Percikan memakai fxr() (pengacak kosmetik), jadi tidak memengaruhi hasil pertandingan.
const SHADOW = { hp: 1.4, damage: 1.1, filter: 'grayscale(1) brightness(.46) sepia(1) hue-rotate(228deg) saturate(2.2)', tint: '#3b1d86', tintAlpha: .62, aura: '139,92,246', wisp: '#a78bfa' };
const FILTER_OK = typeof ctx.filter === 'string'; // peramban tanpa ctx.filter (Safari lama) memakai lapisan warna ungu saja
const DUMMY_MAX = BALANCE.dummyMax;
let shadowFoe = false;
function setShadowFoe(on) { shadowFoe = !!on; BALANCE.dummyMax = shadowFoe ? Math.round(DUMMY_MAX * SHADOW.hp) : DUMMY_MAX; hudIdentity = ''; }
const foeDamage = n => (shadowFoe && n > 0 ? Math.max(1, Math.round(n * SHADOW.damage)) : n);
const dummyHpShown = () => currentDummyHP() * DUMMY_MAX / BALANCE.dummyMax;
function drawShadowAura() {
const x = dummy.x, y = dummy.y - 88, k = .5 + .5 * Math.sin(realTime * 3.4), g = ctx.createRadialGradient(x, y, 10, x, y, 150);
g.addColorStop(0, `rgba(${SHADOW.aura},${(.2 + .08 * k).toFixed(3)})`); g.addColorStop(1, `rgba(${SHADOW.aura},0)`);
ctx.save(); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, 150, 0, 7); ctx.fill(); ctx.restore();
}
// Lapisan warna ungu hanya di piksel sprite (seperti kilat putih drawFlash); dipakai hanya bila peramban tidak punya ctx.filter.
function drawShadowTint(img, r, dx, dy) {
flashCv.width = r.w; flashCv.height = r.h; flashCx.drawImage(img, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h);
flashCx.globalCompositeOperation = 'source-atop'; flashCx.fillStyle = SHADOW.tint; flashCx.fillRect(0, 0, r.w, r.h); flashCx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = SHADOW.tintAlpha; ctx.drawImage(flashCv, dx, dy, r.w, r.h); ctx.globalAlpha = 1;
}
function shadowFx(dt) {
if (menuOpen || dummy.ko || fxr() >= dt * 14) return;
fxPart(dummy.x + (fxr() - .5) * 54, dummy.y - 8 - fxr() * 120, (fxr() - .5) * 24, -46 - fxr() * 54, .9, 3 + fxr() * 4, SHADOW.wisp, 'ember', { g: -26, drag: 1 });
}
function emitRoundCue(cue,round) {
const event={cue,round,text:cue==='round'?`Round ${round}`:'Fight!'};roundCues.push(event);if(roundCues.length>12)roundCues.shift();
if(audio)menuSound(cue);if(roundAnnouncer)try{roundAnnouncer(event);}catch(_){}
@ -258,7 +287,7 @@
}
function advanceMatch(dt) {
if(!Rules||!match||match.mode!=='versus'||vsHold)return;
for(const event of Rules.tick(match,dt,hero.hp,currentDummyHP())) {
for(const event of Rules.tick(match,dt,hero.hp,dummyHpShown())) {
if(event.type==='cue')emitRoundCue(event.cue,event.round);
if(event.type==='round-end') {
matchBest=Math.max(matchBest,bestCombo);clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0;
@ -274,7 +303,7 @@
if(event.type==='next-round'){resetCombat();announceTimer=0;emitRoundCue('round',event.round);}
if(event.type==='complete'){
// bestCombo (kombo terbaik ronde terakhir) tetap dasar bonus koin; matchBest (seluruh ronde) hanya untuk layar hasil.
const info={winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,bestCombo,matchBest:Math.max(matchBest,bestCombo),p2:p2Mode,perfect:match.winner==='player'&&match.enemyWins===0};
const info={winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,bestCombo,matchBest:Math.max(matchBest,bestCombo),p2:p2Mode,perfect:match.winner==='player'&&match.enemyWins===0,boss:shadowFoe};
const metaHandled=!!window.Meta?.award?.(info);
if(!metaHandled)window.FrontEnd?.showResult({...info,mode:'versus',award:window.Meta?.lastAward||null});
}
@ -1538,7 +1567,7 @@
if (dummy.damage >= BALANCE.dummyMax) { dummy.ko = true; dummy.state = 'down'; dummy.vy = -330; dummy.vx = 0; dummy.fallDirection = hero.x <= dummy.x ? 1 : -1; announce('K.O.', .9); }
return true;
}
hero.hp = Math.max(0, hero.hp - amount);
amount = foeDamage(amount); hero.hp = Math.max(0, hero.hp - amount);
if (numbers) damageNumbers.push({ x: hero.x + (random() - .5) * 30, y: hero.y - 120, amount, kind: Feel.classify(amount), life: .9, maxLife: .9, color });
if (amount >= Feel.table.medium) hero.flash = hero.flashMax = Feel.table.hit.medium.flash;
if (!hero.hp) { hero.action = null; hero.hurtTime = 0; state('down'); hero.koTime = 1.7; clearInput(); if (match?.mode !== 'versus') announce('CORE RESTART', 1.7); }
@ -2452,6 +2481,7 @@
for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vy += (p.g ?? (p.kind === 'dust' || p.kind==='run-smoke' ? -40 : 680)) * dt; p.vx *= Math.exp(-dt * (p.drag ?? 3)); if (p.life <= 0) particles.splice(i, 1); }
for (let i = effects.length - 1; i >= 0; i--) { effects[i].life -= dt; if (effects[i].life <= 0) effects.splice(i, 1); }
Feel.ageNumbers(damageNumbers, dt);
if (shadowFoe) shadowFx(dt);
}
function drawBackground(shakeX, shakeY) {
@ -2518,10 +2548,14 @@
function drawDummy() {
const pose=opponentPose(),m=pose.manifest,img=images[opponentCharacter==='fenr'?(dummy.form==='wolf'?'fenrWolf':'fenrHuman'):(cfFormOn(opponentCharacter,'enemy')?opponentCharacter+'-form':opponentCharacter)];
shadow(dummy.x,CONFIG.groundY,dummy.state==='down'?65:opponentCharacter==='mira'?61:opponentCharacter==='haldor'?55:43,.20);
if(shadowFoe)drawShadowAura();
if(!m||!img)return;
const r=m.frame_layout.rows[pose.state][pose.frame];
ctx.save();ctx.translate(Math.round(dummy.x),Math.round(dummy.y));{const om=CF[opponentCharacter]?(cfFormOn(opponentCharacter,'enemy')?CF[opponentCharacter].mode?.form?.metrics:CF[opponentCharacter].metrics):null,k=stateK(om,pose.state);ctx.scale((dummy.facing||-1)*k,k);}ctx.imageSmoothingEnabled=false;
ctx.drawImage(img,r.x,r.y,r.w,r.h,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y),r.w,r.h);if(dummy.flash>0)drawFlash(dummy,img,r,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y));ctx.restore();
if(shadowFoe&&FILTER_OK)ctx.filter=SHADOW.filter;
ctx.drawImage(img,r.x,r.y,r.w,r.h,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y),r.w,r.h);
if(shadowFoe){if(FILTER_OK)ctx.filter='none';else drawShadowTint(img,r,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y));}
if(dummy.flash>0)drawFlash(dummy,img,r,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y));ctx.restore();
if(dummy.action && !dummy.action.fired && dummy.action.type!=='ultimate') {
ctx.save();ctx.strokeStyle='#f0b15e';ctx.lineWidth=2;ctx.globalAlpha=.7;
ctx.beginPath();ctx.ellipse(dummy.x,CONFIG.groundY+3,37,7,0,0,Math.PI*2);ctx.stroke();ctx.restore();
@ -2750,9 +2784,10 @@
function hudFit(el, name) { el.style.fontSize = ''; if (name.length > 10) { const fs = parseFloat(getComputedStyle(el).fontSize); if (fs) el.style.fontSize = (fs * Math.min(1, 10 / name.length) * 1.05) + 'px'; } }
function updateFighterIdentity() {
if(!F)return;
const identity=selectedCharacter+(bwx.rage.player>0&&modeOf(selectedCharacter)?'*mode':'')+':'+hero.form+':'+opponentCharacter+':'+dummy.form;
const identity=selectedCharacter+(bwx.rage.player>0&&modeOf(selectedCharacter)?'*mode':'')+':'+hero.form+':'+opponentCharacter+':'+dummy.form+(shadowFoe?'*shadow':'');
if(identity!==hudIdentity) {
hudIdentity=identity;const me=fighterInfo(selectedCharacter,hero.form),rival=fighterInfo(opponentCharacter,dummy.form);
if(shadowFoe){rival.cls=rival.name;rival.name='BAYANGAN';rival.title='SALINAN GELAP';}$('#enemy-portrait').classList.toggle('shadow-foe',shadowFoe);$('.fighter-two').classList.toggle('shadow-foe',shadowFoe);
if(bwx.rage.player>0&&modeOf(selectedCharacter)){const M=modeOf(selectedCharacter);me.names=M.names;me.icons=M.icons.map(n=>'assets/'+selectedCharacter+'/ui/icon-'+n+'.webp');}
$('#player-name').textContent=me.name;hudFit($('#player-name'),me.name);$('#player-class').textContent=me.cls;$('#deck-character').textContent=me.deck;
$('#player-title').textContent=me.title;
@ -2860,7 +2895,7 @@
canvas.dataset.poseScale = String(currentPose.scale);
canvas.dataset.posePivot = currentPose.pivot ? `${currentPose.pivot.x},${currentPose.pivot.y}` : '';
canvas.dataset.poseWorldPivot = currentPose.worldPivot ? `${currentPose.worldPivot.x},${currentPose.worldPivot.y}` : '';
const h=hpLayers(hero.hp), dh=hpLayers(currentDummyHP());
const h=hpLayers(hero.hp), dh=hpLayers(dummyHpShown());
$('#health-fill').style.width = `${h.front}%`; $('#health-reserve').style.width = `${h.reserve}%`; $('#health-label').textContent = `${hero.hp} / ${BALANCE.heroMax}`;
$('#guard-fill').style.width = `${dh.front}%`; $('#guard-reserve').style.width = `${dh.reserve}%`; $('#guard-label').textContent = `HP ${currentDummyHP()} / ${BALANCE.dummyMax}`;
$('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING');
@ -2940,6 +2975,10 @@
Object.assign(window.__game, { passiveMod, passives: () => ({ seed, player: { id: selectedCharacter, style: sideStyle.player, mods: { ...sideMods.player } }, enemy: { id: opponentCharacter, style: sideStyle.enemy, mods: { ...sideMods.enemy } } }), setStyles: styles => { refreshPassives(styles); hudIdentity = ''; return sideStyle; } });
// Hanya untuk uji headless (tests/t1-feel.mjs): pukulan langsung ke lawan, tabel rasa kena, status K.O./angka.
Object.assign(window.__game, { hitDummy, feel: Feel, feelState: () => ({ ko: { ...Feel.ko.state, scale: Feel.ko.scale(), zoom: Feel.ko.zoom() }, kick: Feel.kickOffset(), numbers: damageNumbers.map(n => ({ amount: n.amount, kind: n.kind, label: n.label, color: n.color, life: n.life })), hitstop, trauma, realTime }) });
// Bayangan (RENCANA 3.6): damage musuh ke pemain +10% lewat satu pintu (semua serangan musuh memanggil window.__game.receiveHit; DoT lewat dotDamage).
// shadowFoe() melaporkan apakah pertandingan sekarang melawan Bayangan (uji manual).
{ const baseHit = window.__game.receiveHit; window.__game.receiveHit = (damage, options) => baseHit(foeDamage(damage), options); }
window.__game.shadowFoe = () => shadowFoe;
if(F)reset();
if(window.FRONTEND_ENABLED)setMenuOpen(true);
resize(); requestAnimationFrame(tick);

View File

@ -1,8 +1,8 @@
<!doctype html>
<html lang="id">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
<link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.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="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
<link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"></head>
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span><p class="boot-quote"></p></div></div><script src="content/loading.js"></script><script src="content/changelog.js"></script><script src="brand.js"></script><script src="content/story.js"></script><script src="story.js"></script><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
<header class="combat-header">
<section class="fighter fighter-one" aria-label="Status pemain">

100
meta.js
View File

@ -56,6 +56,7 @@
logo: ['TELITI', 'Temukan rahasia di judul.'],
parry: ['TEPAT WAKTU', 'Lakukan parry yang berhasil.'],
arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'],
bayangan: ['BAYANGAN', 'Hadapi sosok yang menunggu di ujung Arcade. Gelar: PENAKLUK BAYANGAN.'],
survival5: ['BERTAHAN', 'Menang 5 kali berturut-turut di Survival.'],
twoplayer: ['TEMAN BERMAIN', 'Mainkan satu pertandingan dua pemain.'],
special: ['SPESIALIS', 'Beli satu varian skill karakter.'],
@ -72,10 +73,11 @@
// ---- toasts ----
let toastBox, held = [];
// Selama layar VS tampil toast ditahan (menutupi bilah atas VS), lalu dimunculkan penuh setelah layar selesai atau dibatalkan.
function holdToasts() { setTimeout(() => { if (window.VS?.active) return holdToasts(); const q = held; held = []; for (const [m, k] of q) toast(m, k); }, 150); }
// Selama layar VS atau kartu cerita (story.js) tampil toast ditahan (menutupi bilah atas VS / teks cerita), lalu dimunculkan penuh setelah layar selesai atau dibatalkan.
const busy = () => window.VS?.active || window.Story?.active;
function holdToasts() { setTimeout(() => { if (busy()) return holdToasts(); const q = held; held = []; for (const [m, k] of q) toast(m, k); }, 150); }
function toast(msg, kind = '') {
if (window.VS?.active) { held.push([msg, kind]); if (held.length === 1) holdToasts(); return; }
if (busy()) { held.push([msg, kind]); if (held.length === 1) holdToasts(); return; }
if (!toastBox) { toastBox = document.createElement('div'); toastBox.id = 'meta-toasts'; document.body.append(toastBox); }
const el = document.createElement('div'); el.className = 'meta-toast ' + kind; el.textContent = msg; toastBox.append(el);
setTimeout(() => el.classList.add('out'), 3200); setTimeout(() => el.remove(), 3800);
@ -133,47 +135,93 @@
return handled;
}
// ---- Arcade (fight everyone once, one life) and Survival (endless, harder each win) ----
// Arcade memakai kartu cerita (story.js, RENCANA 3.5): pembuka, antar-lawan, bos, dan akhir. Setelah semua lawan kalah menunggu bos rahasia BAYANGAN
// (RENCANA 3.6): salinan gelap karakter pemain, tingkat excellent, HP +40% dan damage +10% (game.js startMatch {shadow: true}). Hadiah tamat dibayar begitu
// lawan terakhir kalah (seperti dulu); menang melawan Bayangan memberi +1000 koin, pencapaian BAYANGAN, dan gelar; kalah tetap dianggap tamat tanpa bonus bos.
// run.boss: null | 'pending' (kartu bos) | 'fight' (pertandingan Bayangan). Survival tidak memakai cerita.
let run = null;
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
function startRun(kind, player) {
run = { kind, player, wins: 0, queue: kind === 'arcade' ? shuffle(roster().filter(x => x !== player)) : [] };
nextFight();
const ARCADE_COINS = 500, BOSS_COINS = 1000;
const stageOrder = () => shuffle(Object.keys(window.MatchRules?.stages || { bellora: 1 })).concat('bellora');
// ?arcade=N (uji manual) memendekkan Arcade menjadi N lawan.
const arcadeLimit = () => { try { return Math.max(0, parseInt(new URLSearchParams(location.search).get('arcade'), 10) || 0); } catch (_) { return 0; } };
// Kartu cerita lalu langkah berikutnya. Tanpa modul/teks (atau bila kartu gagal) langsung lanjut; next dipanggil tepat satu kali.
function story(card, next) {
let shown = false;
try { shown = !!window.Story?.show?.(card, next); } catch (e) { console.error(e); }
if (!shown) next();
}
function nextFight() {
const g = window.__game, stages = Object.keys(window.MatchRules?.stages || { bellora: 1 });
function startRun(kind, player) {
let queue = kind === 'arcade' ? shuffle(roster().filter(x => x !== player)) : [];
const lim = arcadeLimit(); if (kind === 'arcade' && lim > 0) queue = queue.slice(0, lim);
const r = run = { kind, player, wins: 0, queue, boss: null, bonus: 0 };
if (kind === 'arcade') story({ kind: 'intro', player, total: queue.length }, () => { if (run === r) nextFight(); });
else nextFight();
}
function nextFight(order) {
const g = window.__game;
const enemy = run.kind === 'arcade' ? run.queue[run.wins] : shuffle(roster().filter(x => x !== run.player))[0];
const level = run.kind === 'arcade' ? 'medium' : run.wins < 3 ? 'easy' : run.wins < 6 ? 'medium' : run.wins < 9 ? 'hard' : 'excellent';
let ok = false;
for (const st of shuffle(stages.slice()).concat('bellora')) { ok = g.startMatch({ player: run.player, enemy, stage: st, level, mode: 'versus', vs: { label: (run.kind === 'arcade' ? 'ARCADE · LAWAN ' + (run.wins + 1) + ' / ' + run.queue.length : 'SURVIVAL · LAWAN ' + (run.wins + 1)) } }); if (ok) break; }
for (const st of order || stageOrder()) { ok = g.startMatch({ player: run.player, enemy, stage: st, level, mode: 'versus', vs: { label: (run.kind === 'arcade' ? 'ARCADE · LAWAN ' + (run.wins + 1) + ' / ' + run.queue.length : 'SURVIVAL · LAWAN ' + (run.wins + 1)) } }); if (ok) break; }
if (!ok) { toast('Aset belum siap, coba lagi.'); run = null; window.FrontEnd?.home(); }
}
// Setelah lawan kalah: Arcade menampilkan kartu antar-lawan (panggung lawan berikutnya sama dengan latar kartunya), Survival langsung lanjut.
function betweenFights() {
const r = run; if (!r) return;
if (r.kind !== 'arcade') return nextFight();
const order = stageOrder();
story({ kind: 'between', player: r.player, enemy: r.queue[r.wins], step: r.wins + 1, total: r.queue.length, stage: order[0] }, () => { if (run === r) nextFight(order); });
}
// Lawan terakhir kalah: hadiah tamat langsung dibayar, lalu kartu bos dan pertandingan Bayangan di Moonrise Bastion (malam bulan purnama).
function arcadeCleared(r) {
r.bonus = ARCADE_COINS; addCoins(r.bonus); unlock('arcade_clear'); r.boss = 'pending';
setTimeout(() => { if (run === r) story({ kind: 'boss', player: r.player }, () => { if (run === r) startBoss(); }); }, 2600);
return true;
}
function startBoss() {
const r = run, g = window.__game; r.boss = 'fight';
let ok = false;
for (const st of ['moonrise'].concat(stageOrder())) { ok = g.startMatch({ player: r.player, enemy: r.player, stage: st, level: 'excellent', mode: 'versus', shadow: true, vs: { label: 'ARCADE · BOS RAHASIA' } }); if (ok) break; }
if (!ok) { r.boss = null; finishRun(true); } // aset belum siap: Arcade tetap tamat, tanpa bos
}
function advanceRun(won) {
if (won) {
run.wins++;
if (run.kind === 'arcade' && run.wins >= run.queue.length) { finishRun(true); return true; }
if (run.kind === 'survival') { save.best.survival = Math.max(save.best.survival, run.wins); if (run.wins >= 5) unlock('survival5'); persist(); }
setTimeout(() => { if (run) nextFight(); }, 2600); return true;
const r = run;
if (r.boss === 'fight') { finishRun(true, won ? 'win' : 'lose'); return true; }
if (!won) { finishRun(false); return true; }
r.wins++;
if (r.kind === 'arcade' && r.wins >= r.queue.length) return arcadeCleared(r);
if (r.kind === 'survival') { save.best.survival = Math.max(save.best.survival, r.wins); if (r.wins >= 5) unlock('survival5'); persist(); }
setTimeout(() => { if (run === r) betweenFights(); }, 2600); return true;
}
finishRun(false); return true;
}
function finishRun(cleared) {
// boss: 'win' (Bayangan kalah) | 'lose' (kalah dari Bayangan, Arcade tetap tamat) | null (tanpa bos).
function finishRun(cleared, boss = null) {
const r = run; run = null; let bonus = 0;
if (r.kind === 'arcade' && cleared) { bonus = 500; unlock('arcade_clear'); } else if (r.kind === 'survival') bonus = r.wins * 30;
if (bonus) addCoins(bonus);
if (r.kind === 'arcade' && cleared) { if (!r.bonus) { r.bonus = ARCADE_COINS; addCoins(r.bonus); unlock('arcade_clear'); } bonus = r.bonus; } // normalnya sudah dibayar di arcadeCleared
else if (r.kind === 'survival') { bonus = r.wins * 30; if (bonus) addCoins(bonus); }
const bossBonus = boss === 'win' ? BOSS_COINS : 0, total = bonus + bossBonus, fresh = boss === 'win' && !save.ach.bayangan;
const result = () => {
// Layar akhir sama gayanya dengan layar hasil pertandingan (menu.js 'result' lewat ResultView); layar lama tetap jadi cadangan.
if (lastMatch && window.ResultView && window.FrontEnd?.showResult) {
// Satu baris per kelompok supaya muat: lawan sebelumnya, pertandingan terakhir, bonus rangkaian.
const base = lastAward || { gain: 0, lines: [], prev: 0 }, before = cleared ? r.wins - 1 : r.wins, lines = [];
// Satu baris per kelompok supaya muat: lawan sebelumnya, pertandingan terakhir, bonus rangkaian (bonus tamat dan Bayangan digabung).
const base = lastAward || { gain: 0, lines: [], prev: 0 }, before = cleared && !boss ? r.wins - 1 : r.wins, lines = [];
if (base.prev > 0) lines.push({ key: 'prev', label: 'Lawan sebelumnya', amount: base.prev, note: before + ' pertandingan' });
lines.push({ key: 'last', label: 'Pertandingan terakhir', amount: base.gain, note: cleared ? 'Menang' : 'Kalah' });
if (bonus) lines.push({ key: 'run', label: r.kind === 'arcade' ? 'Bonus Arcade tamat' : 'Bonus rangkaian menang', amount: bonus, note: r.kind === 'arcade' ? '' : r.wins + ' x 30' });
window.FrontEnd.showResult({ ...lastMatch, mode: 'versus', award: { ...base, lines, gain: base.prev + base.gain + bonus }, run: { kind: r.kind, wins: r.wins, total: r.queue.length, cleared, best: save.best.survival, bonus } });
lines.push({ key: 'last', label: boss ? 'Melawan Bayangan' : 'Pertandingan terakhir', amount: base.gain, note: base.won ? 'Menang' : 'Kalah' });
if (total) lines.push({ key: 'run', label: bossBonus ? 'Bonus tamat + Bayangan' : r.kind === 'arcade' ? 'Bonus Arcade tamat' : 'Bonus rangkaian menang', amount: total, note: bossBonus ? bonus + ' + ' + bossBonus : r.kind === 'arcade' ? '' : r.wins + ' x 30' });
window.FrontEnd.showResult({ ...lastMatch, mode: 'versus', award: { ...base, lines, gain: base.prev + base.gain + total }, run: { kind: r.kind, wins: r.wins, total: r.queue.length, cleared, best: save.best.survival, bonus: total, boss } });
return;
}
if (!root) build(); root.hidden = false;
const title = r.kind === 'arcade' ? (cleared ? 'ARCADE TAMAT!' : 'ARCADE BERAKHIR') : 'SURVIVAL BERAKHIR';
const sub = r.kind === 'arcade' ? (cleared ? 'Semua lawan dikalahkan.' : 'Kalah di lawan ke-' + (r.wins + 1) + ' dari ' + r.queue.length + '.') : 'Kemenangan beruntun: ' + r.wins + ' · rekor ' + save.best.survival;
root.innerHTML = `<div class="meta-panel run-end"><h2>${title}</h2><p>${sub}</p><p class="meta-coin">${bonus ? 'Bonus ◆ +' + bonus : ''}</p><p><button class="mt on" data-runmenu>KE MENU UTAMA</button></p></div>`;
const sub = r.kind === 'arcade' ? (cleared ? (boss === 'win' ? 'Bayangan dikalahkan.' : 'Semua lawan dikalahkan.') : 'Kalah di lawan ke-' + (r.wins + 1) + ' dari ' + r.queue.length + '.') : 'Kemenangan beruntun: ' + r.wins + ' · rekor ' + save.best.survival;
root.innerHTML = `<div class="meta-panel run-end"><h2>${title}</h2><p>${sub}</p><p class="meta-coin">${total ? 'Bonus ◆ +' + total : ''}</p><p><button class="mt on" data-runmenu>KE MENU UTAMA</button></p></div>`;
};
// Arcade tamat: akhir cerita khas karakter dulu, baru layar hasil. Hadiah Bayangan (koin, pencapaian, gelar) diberikan sesudah kartu mulai agar toast-nya ditahan sampai kartu selesai.
if (r.kind === 'arcade' && cleared) story({ kind: 'ending', player: r.player, boss, fresh }, result); else result();
if (boss === 'win') {
addCoins(bossBonus); save.title = window.Story?.title || 'PENAKLUK BAYANGAN'; persist();
if (unlock('bayangan')) toast('GELAR BARU: ' + save.title, 'ach');
}
}
// ---- UI ----

103
story.css Normal file
View File

@ -0,0 +1,103 @@
/* Kartu cerita Arcade (story.js, RENCANA 3.5 dan 3.6) dan tampilan Bayangan di HUD, layar VS, dan layar hasil. Tinggi minimum yang didukung: 520 px; ada aturan ringkas untuk <= 420 px. */
#story-screen{--c:#e0bf85;--ah:min(86vh,40vw);--aw:calc(var(--ah)*.767);position:fixed;inset:0;z-index:45;overflow:hidden;background:#07131c;color:#f6eedb;font-family:Rajdhani,Arial,sans-serif;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:manipulation;animation:st-fade .28s ease-out}
#story-screen.out{opacity:0;transition:opacity .26s ease-in;pointer-events:none}
#story-screen>*{margin:0}
.st-bg{position:absolute;inset:-3%;background:#0e202c center/cover no-repeat;will-change:transform;animation:st-zoom 16s ease-out both}
.st-bg:after{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at 45% 55%,#050f17b0 15%,#04101af5 100%)}
.st-band{position:absolute;inset:0;z-index:1;pointer-events:none;clip-path:polygon(0 0,52% 0,38% 100%,0 100%);background:linear-gradient(100deg,var(--c) 0%,#0c1c2800 92%);opacity:.34;animation:st-band .5s cubic-bezier(.2,.9,.25,1) both}
.st-band:after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(115deg,transparent 0 26px,#ffffff10 26px 28px)}
.st-top{position:absolute;z-index:6;top:clamp(8px,2.4vh,22px);left:0;right:0;display:flex;justify-content:center;align-items:center;gap:12px;font-weight:700;font-size:clamp(11px,1.5vh + 3px,15px);letter-spacing:3px;color:#e9dcb6;text-shadow:0 2px 6px #000a;animation:st-fade .4s .15s both}
.st-top span{padding:2px 12px;background:#06121bc4}.st-top i{width:6px;height:6px;background:#e0bf85;transform:rotate(45deg)}
/* Potret besar (kartu pembuka, bos, dan akhir): pemain di kiri, Bayangan (bos) di kanan. */
.st-fig{position:absolute;bottom:0;height:var(--ah);z-index:2;pointer-events:none}
.st-fig img{display:block;height:100%;width:auto;max-width:none;filter:drop-shadow(0 14px 18px #000b)}
.st-fig.l{left:3vw;animation:st-in-l .6s .08s cubic-bezier(.2,.9,.25,1) both}
.st-fig.r{right:3vw;animation:st-in-r .6s .18s cubic-bezier(.2,.9,.25,1) both}
.st-fig.r img{transform:scaleX(-1)}
.st-fig.shadow img{filter:grayscale(1) brightness(.5) sepia(1) hue-rotate(228deg) saturate(2.2) drop-shadow(0 0 22px #7c4dffcc)}
/* Kartu teks: berada di antara potret dan tepi kanan; tinggi dibatasi layar, isi di tengah (margin auto agar tidak terpotong bila meluap). */
.st-card{position:absolute;z-index:3;left:calc(3vw + var(--aw) + 2.5vw);right:4vw;top:clamp(34px,8vh,72px);bottom:clamp(26px,6vh,56px);display:flex;flex-direction:column;overflow:hidden auto;scrollbar-width:none}
.st-card::-webkit-scrollbar{display:none}
.st-body{margin:auto 0;display:flex;flex-direction:column;gap:clamp(5px,1.3vh,12px);min-width:0}
.st-kicker{display:block;font-weight:700;font-size:clamp(10px,1.4vh + 2px,14px);letter-spacing:4px;color:#d8c394;animation:st-fade .45s .15s both}
.st-title{margin:0;font:700 italic clamp(26px,6.6vh,66px)/.95 Rajdhani,'Arial Narrow',sans-serif;letter-spacing:1px;color:#f3d796;text-shadow:3px 4px 0 #6a4a14,0 0 30px #ffd47a55;animation:st-title .5s .12s cubic-bezier(.2,1.4,.4,1) both}
.st-text{display:flex;flex-direction:column;gap:clamp(5px,1.2vh,11px);min-width:0;padding:clamp(5px,1.2vh,12px) 0 clamp(5px,1.2vh,12px) clamp(10px,1.5vw,20px);border-left:3px solid var(--c)}
.st-line{margin:0;font:600 clamp(15px,3vh + 3px,27px)/1.3 Rajdhani,sans-serif;color:#fff3d4;text-shadow:0 1px 4px #000a;overflow-wrap:anywhere;animation:st-line .55s calc(.35s + var(--i,0)*.95s) cubic-bezier(.2,.9,.25,1) both}
.st-gelar{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px;padding:clamp(4px,1vh,9px) 14px;background:linear-gradient(90deg,#3b1d86cc,#3b1d8600);border-left:3px solid #c9b3ff;animation:st-line .55s calc(.35s + var(--i,0)*.95s) cubic-bezier(.2,.9,.25,1) both}
.st-gelar small{font-weight:700;font-size:clamp(10px,1.4vh + 2px,13px);letter-spacing:4px;color:#c9b3ff}
.st-gelar b{font:700 italic clamp(18px,3.6vh + 4px,34px)/1 Rajdhani,sans-serif;letter-spacing:1px;color:#fff0c4;text-shadow:2px 2px 0 #3b1d86}
.st-skip{position:absolute;z-index:6;bottom:clamp(6px,1.6vh,14px);left:0;right:0;text-align:center;font-weight:700;font-size:11px;letter-spacing:3px;color:#b9c9cc;animation:st-fade .5s .9s both}
.st-timer{position:absolute;z-index:7;left:0;right:0;bottom:0;height:3px;background:#ffffff1c}.st-timer i{display:block;height:100%;background:#e0bf85;transform-origin:0 50%;animation:st-timer var(--st-run,4000ms) linear both}
.st-confetti{position:absolute;inset:0;z-index:4;overflow:hidden;pointer-events:none}
.st-confetti i{position:absolute;top:-16px;left:var(--x);width:var(--w);height:calc(var(--w)*1.6);background:var(--cc);opacity:0;animation:st-fall var(--t) var(--d) ease-in both}
/* Kartu antar-lawan: dua potret persegi di kiri dan kanan, teks di tengah, titik kemajuan Arcade di atasnya. */
#story-screen.st-between{--fw:clamp(84px,min(34vh,22vw),300px)}
.st-between .st-band{display:none}
.st-between .st-bg:after{background:radial-gradient(ellipse at 50% 50%,#050f17c0 10%,#04101af8 100%)}
.st-between .st-card{left:4vw;right:4vw}
.st-between .st-body{width:100%;max-width:1100px;margin:auto;align-items:center}
.st-between .st-line{font-size:clamp(15px,3.1vh + 3px,28px)}
.st-dots{display:flex;gap:9px;justify-content:center;animation:st-fade .45s .2s both}
.st-dots i{width:10px;height:10px;transform:rotate(45deg);background:#ffffff26;border:1px solid #e0bf8566}
.st-dots i.done{background:#e0bf85}.st-dots i.now{background:var(--c);border-color:#fff;box-shadow:0 0 10px var(--c)}
.st-vs{display:flex;align-items:center;justify-content:center;gap:clamp(10px,2.6vw,36px);width:100%}
.st-vs .st-text{flex:1 1 0;max-width:640px}
.st-face{flex:none;margin:0;width:var(--fw);display:flex;flex-direction:column;align-items:center;gap:6px;animation:st-pop .5s .1s cubic-bezier(.2,1.2,.3,1) both}
.st-face .fr{display:block;width:var(--fw);height:var(--fw);overflow:hidden;background:#11232b;border:2px solid var(--c);clip-path:polygon(0 0,84% 0,100% 16%,100% 100%,0 100%)}
.st-face.r .fr{clip-path:polygon(16% 0,100% 0,100% 100%,0 100%,0 16%)}
.st-face img{display:block;width:100%;height:100%;object-fit:cover}.st-face.r img{transform:scaleX(-1)}
.st-face figcaption{max-width:calc(var(--fw) + 24px);text-align:center;font:700 clamp(11px,1.7vh + 3px,18px)/1.1 Rajdhani,sans-serif;letter-spacing:2px;color:#f1e6c6;text-shadow:0 2px 4px #000a}
/* Kartu bos: potret pemain dan bayangannya berhadapan, teks di tengah dengan nuansa ungu. */
#story-screen.st-boss{--ah:min(78vh,34vw)}
.st-boss .st-band{display:none}
.st-boss .st-bg:after{background:radial-gradient(ellipse at 50% 55%,#160a30b8 10%,#07031af5 100%)}
.st-boss .st-card{left:calc(3vw + var(--aw) + 2vw);right:calc(3vw + var(--aw) + 2vw)}
.st-boss .st-kicker,.st-boss .st-title{text-align:center}
.st-boss .st-kicker{color:#c9b3ff}
.st-boss .st-title{color:#e4d8ff;text-shadow:3px 4px 0 #3b1d86,0 0 30px #8b5cf6aa}
.st-boss .st-line{font-size:clamp(14px,2.7vh + 3px,25px)}
.st-boss .st-timer i{background:#8b5cf6}
@keyframes st-fade{from{opacity:0}to{opacity:1}}
@keyframes st-zoom{from{transform:scale(1.12)}to{transform:scale(1.02)}}
@keyframes st-band{from{transform:translateX(-100%)}to{transform:none}}
@keyframes st-in-l{from{transform:translateX(-40vw);opacity:0}to{transform:none;opacity:1}}
@keyframes st-in-r{from{transform:translateX(40vw);opacity:0}to{transform:none;opacity:1}}
@keyframes st-line{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
@keyframes st-title{from{transform:scale(1.35) skewX(-6deg);opacity:0}to{transform:none;opacity:1}}
@keyframes st-pop{from{transform:scale(.8);opacity:0}to{transform:none;opacity:1}}
@keyframes st-timer{from{transform:scaleX(0)}to{transform:scaleX(1)}}
@keyframes st-fall{0%{opacity:0;transform:translateY(0) rotate(0)}8%{opacity:1}100%{opacity:.9;transform:translateY(105vh) rotate(var(--r))}}
/* Layar pendek (ponsel lanskap, 844x390) atau sempit: potret lebih kecil, teks sedikit lebih rapat. */
@media(max-width:760px),(max-height:420px){
#story-screen{--ah:min(80vh,44vw)}
#story-screen.st-boss{--ah:min(70vh,36vw)}
.st-kicker{letter-spacing:3px}
.st-boss .st-line{font-size:clamp(13px,2.6vh + 3px,20px)}
.st-skip{font-size:10px;letter-spacing:2px}
}
/* Layar tegak (jarang): potret di atas, teks di bawahnya. */
@media(max-aspect-ratio:1/1){
#story-screen{--ah:min(32vh,60vw)}
#story-screen.st-boss{--ah:min(30vh,44vw)}
.st-fig{bottom:auto;top:clamp(34px,7vh,60px)}
.st-card,.st-boss .st-card{left:5vw;right:5vw;top:calc(var(--ah) + clamp(44px,10vh,80px));bottom:clamp(26px,5vh,48px)}
.st-between .st-card{top:clamp(34px,8vh,72px)}
.st-line,.st-boss .st-line,.st-between .st-line{font-size:clamp(14px,2.3vh + 3px,22px)}
.st-vs{display:grid;grid-template-columns:1fr 1fr}.st-vs .st-text{grid-row:2;grid-column:1/-1;max-width:none}
.st-face.l{grid-row:1;grid-column:1;justify-self:end}.st-face.r{grid-row:1;grid-column:2;justify-self:start}
}
@media(prefers-reduced-motion:reduce){#story-screen,#story-screen *{animation:none!important}.st-confetti{display:none}.st-timer i{transform:none}}
/* Toast yang sudah tampil sebelum kartu (mis. koin lawan terakhir) tidak menutupi bilah atas kartu; toast baru ditahan Meta sampai kartu selesai. */
body:has(#story-screen) #meta-toasts{visibility:hidden}
/* ---- Bayangan di layar VS (Story.decorateVs), HUD (game.js updateFighterIdentity), dan layar hasil (vs.js view) ---- */
#vs-screen.vs-shadow{--pr:#5b34c2}
#vs-screen.vs-shadow .vs-bg:after{background:radial-gradient(ellipse at 50% 50%,#0c0524b8 25%,#07031af2 100%)}
#vs-screen.vs-shadow .vs-slash{background:linear-gradient(180deg,#f1eaff,#8b5cf6);box-shadow:0 0 20px #b49cff}
#vs-screen.vs-shadow .vs-side.r .vs-art{filter:grayscale(1) brightness(.5) sepia(1) hue-rotate(228deg) saturate(2.2) drop-shadow(0 14px 18px #000b) drop-shadow(0 0 20px #7c4dffaa)}
.fighter.fighter-two.shadow-foe .life-fill{background:linear-gradient(180deg,#e9deff 0,#bba3ff 25%,#8059e6 28%,#6a46d4)}
.fighter.fighter-two.shadow-foe .life-fill.reserve-fill{background:linear-gradient(180deg,#cdb8ff,#8a64ee 35%,#4d2fae)}
.fighter.fighter-two.shadow-foe #enemy-name{color:#dccfff;text-shadow:2px 2px 0 #1a0f3a,0 0 14px #8b5cf699}
#enemy-portrait.shadow-foe{filter:grayscale(1) brightness(.6) sepia(1) hue-rotate(228deg) saturate(2.2)}
.rs-fighter.lose.shadow .rs-art{filter:grayscale(1) brightness(.5) sepia(1) hue-rotate(228deg) saturate(2.2) drop-shadow(0 10px 12px #0008);opacity:.9}
.rs-fighter.lose.shadow .rs-plate small{color:#b79cff}

147
story.js Normal file
View File

@ -0,0 +1,147 @@
/* Mode cerita Arcade (RENCANA 3.5) dan bos rahasia Bayangan (RENCANA 3.6): kartu cerita serta tampilan Bayangan di layar VS dan hasil.
Teks: content/story.js (window.STORY). Gaya: story.css (gaya Bayangan di HUD, VS, dan layar hasil juga ada di sana).
- Story.show(card, onDone): kartu layar penuh. card = {kind: 'intro' | 'between' | 'boss' | 'ending', player, enemy?, step?, total?, stage?, boss?}.
onDone dipanggil tepat satu kali: klik/ketuk/Spasi/Enter/Esc (setelah 0,5 dtk) atau otomatis (sekitar 4 dtk untuk kartu antar-lawan, lebih lama untuk kartu panjang).
Mengembalikan false, tanpa kartu dan tanpa onDone, bila tidak ada teks; pemanggil lalu melanjutkan sendiri.
- Story.decorateVs(info): game.js memanggilnya tepat setelah VS.show bila lawannya Bayangan (nama 'BAYANGAN <NAMA>', warna ungu, ucapan bos).
- Story.shadowFighter(f): data petarung vs.js menjadi versi Bayangan; Story.bossQuote(kind, id): ucapan Bayangan (kind: vs | win | lose); Story.title: gelar.
Selama kartu tampil semua tombol ditahan (tidak bocor ke menu atau game) dan Meta menahan toast (Story.active). Pengacak di sini kosmetik saja. */
(() => {
'use strict';
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let seed = (Date.now() ^ Math.floor(performance.now() * 1000)) >>> 0;
const rnd = () => { seed = (seed + 0x6D2B79F5) >>> 0; let t = seed; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const MIN_SKIP = 500, FADE = 260;
const NUM = ['nol', 'satu', 'dua', 'tiga', 'empat', 'lima', 'enam', 'tujuh', 'delapan', 'sembilan', 'sepuluh', 'sebelas', 'dua belas'];
const SHADOW = { name: 'BAYANGAN', tag: 'SALINAN GELAP', color: '#8b5cf6', title: 'PENAKLUK BAYANGAN' };
const CONFETTI = ['#ffe08a', '#ffffff', '#c9b3ff', '#8bd4c3'];
// ---- teks ----
const S = () => window.STORY || {};
const list = x => (Array.isArray(x) ? x : x == null ? [] : [x]).map(String).filter(t => t.trim());
const first = (...pools) => { for (const p of pools) { const l = list(p); if (l.length) return l; } return []; };
const oneOf = l => (l.length ? l[Math.floor(rnd() * l.length)] : '');
const fill = (t, v) => String(t).replace(/\{(\w+)\}/g, (m, k) => (v[k] == null ? m : v[k]));
const fighter = id => window.VS?.fighter?.(id) || { id, name: String(id || '?').toUpperCase(), tag: '', art: '', portrait: `assets/${id}/ui/portrait.webp`, color: '#e0bf85' };
const artOf = f => f.art || f.portrait;
const title = () => S().boss?.title || SHADOW.title;
function lines(card) {
const s = S(), g = s.generic || {}, b = s.boss || {}, id = card.player, P = fighter(id), E = card.enemy ? fighter(card.enemy) : null;
const v = { name: P.name, rival: E ? E.name : '', n: NUM[card.total] || String(card.total || '') };
let out = [];
if (card.kind === 'intro') out = first(s.intro?.[id], g.intro);
else if (card.kind === 'between') {
const t = typeof s.between === 'function' ? s.between(id, card.enemy, card.step, card.total) : s.between?.[id]?.[card.enemy];
out = first(t); if (!out.length) out = list(oneOf(list(g.between)));
} else if (card.kind === 'boss') out = list(b.lead).concat(first(b.intro?.[id], g.bossIntro));
else if (card.kind === 'ending') {
out = first(s.ending?.[id], g.ending);
if (card.boss === 'win') out = out.concat(first(b.end?.win?.[id], g.bossEndWin));
else if (card.boss === 'lose') out = out.concat(first(b.end?.lose?.[id], g.bossEndLose));
}
return out.map(t => fill(t, v));
}
function bossQuote(kind, id) {
const s = S(), key = kind === 'win' || kind === 'lose' ? kind : 'vs';
return fill(oneOf(first(s.boss?.[key]?.[id], s.generic?.['boss' + key[0].toUpperCase() + key.slice(1)])), { name: fighter(id).name });
}
const shadowFighter = f => ({ ...f, name: SHADOW.name + ' ' + f.name, tag: SHADOW.tag, color: SHADOW.color, shadow: true });
// ---- layar VS untuk Bayangan ----
// vs.js membangun layar seperti biasa (id sama dengan pemain); di sini sisi kanan diganti menjadi Bayangan (kelas .vs-shadow memberi warna ungu di story.css).
function decorateVs(info) {
const el = document.getElementById('vs-screen'), R = el?.querySelector('.vs-side.r');
if (!info || !R) return false;
const f = shadowFighter(fighter(info.enemy)), set = (sel, text) => { const n = R.querySelector(sel); if (n) n.textContent = text; };
el.classList.add('vs-shadow'); R.style.setProperty('--c', f.color);
set('.vs-who', 'BOS RAHASIA'); set('.vs-name', f.name); set('.vs-tag', f.tag); set('.vs-quote', '“' + bossQuote('vs', info.enemy) + '”');
el.setAttribute('aria-label', `${fighter(info.player).name} melawan ${f.name}. Klik atau tekan Spasi untuk melewati.`);
return true;
}
// ---- kartu cerita ----
const stageOf = id => window.MatchRules?.stages?.[id];
const DEFAULT_STAGE = { intro: 'bellora', ending: 'sunspire', boss: 'moonrise' };
function confetti(n) {
return Array.from({ length: n }, (_, i) => `<i style="--x:${(rnd() * 100).toFixed(1)}%;--d:${(rnd() * 1.6).toFixed(2)}s;--t:${(3 + rnd() * 2.4).toFixed(2)}s;--r:${Math.round(rnd() * 720 - 360)}deg;--w:${6 + Math.round(rnd() * 6)}px;--cc:${CONFETTI[i % CONFETTI.length]}"></i>`).join('');
}
function duration(kind, ls) {
const chars = ls.join('').length;
// Kartu antar-lawan dibaca sekitar 4 dtk; kartu panjang: kalimat muncul bergantian (0,95 dtk) lalu waktu baca.
return kind === 'between' ? clamp(3800 + Math.max(0, chars - 90) * 22, 3800, 6000) : clamp(ls.length * 950 + 3200 + chars * 14, 7000, 15000);
}
function markup(card, ls) {
const kind = card.kind, P = fighter(card.player), E = card.enemy ? fighter(card.enemy) : null, st = stageOf(card.stage || DEFAULT_STAGE[kind]);
const text = `<div class="st-text">${ls.map((t, i) => `<p class="st-line" style="--i:${i}">${esc(t)}</p>`).join('')}</div>`;
const top = (...parts) => `<div class="st-top">${parts.filter(Boolean).map(x => `<span>${esc(x)}</span>`).join('<i></i>')}</div>`;
const art = cls => `<figure class="st-fig ${cls}"><img class="st-art" src="${esc(artOf(P))}" alt="" draggable="false" decoding="async"></figure>`;
const ttl = S().title?.[kind]?.[card.player];
let head, figs = '', body, extra = '', color = P.color;
if (kind === 'between') {
const n = card.total || 0, k = card.step || 1;
const face = (cls, f) => `<figure class="st-face ${cls}" style="--c:${esc(f.color)}"><span class="fr"><img src="${esc(f.portrait)}" alt="" draggable="false" decoding="async"></span><figcaption>${esc(f.name)}</figcaption></figure>`;
head = top('ARCADE', n ? `LAWAN ${k} / ${n}` : '', st ? st.name.toUpperCase() : '');
body = `<small class="st-kicker">LAWAN BERIKUTNYA</small>${n ? `<div class="st-dots" aria-hidden="true">${Array.from({ length: n }, (_, i) => `<i class="${i < k - 1 ? 'done' : i === k - 1 ? 'now' : ''}"></i>`).join('')}</div>` : ''}<div class="st-vs">${face('l', P)}${text}${E ? face('r', E) : ''}</div>`;
} else if (kind === 'boss') {
color = SHADOW.color; head = top('ARCADE', 'BAB RAHASIA', st ? st.name.toUpperCase() : '');
figs = art('l') + art('r shadow');
body = `<small class="st-kicker">BOS RAHASIA</small><h2 class="st-title">${esc(ttl || SHADOW.name)}</h2>${text}`;
} else {
const ending = kind === 'ending';
head = top('ARCADE', ending ? 'EPILOG' : 'PROLOG', st ? st.name.toUpperCase() : '');
figs = art('l');
if (ending) extra = `<div class="st-confetti" aria-hidden="true">${confetti(24)}</div>`;
body = `<small class="st-kicker">${esc(P.name)}${P.tag ? ' · ' + esc(P.tag) : ''}</small><h2 class="st-title">${esc(ttl || (ending ? 'TAMAT' : 'PROLOG'))}</h2>${text}`
+ (ending && card.boss === 'win' ? `<div class="st-gelar" style="--i:${ls.length}"><small>${card.fresh ? 'GELAR BARU' : 'GELAR'}</small><b>${esc(title())}</b></div>` : '');
}
const bg = st?.image;
const hint = document.documentElement.classList.contains('touch') ? 'KETUK UNTUK LANJUT' : 'KLIK ATAU SPASI UNTUK LANJUT';
return { color, html: `<div class="st-bg"${bg ? ` style="background-image:url('${esc(bg)}')"` : ''}></div><i class="st-band"></i>${head}${figs}<div class="st-card"><div class="st-body">${body}</div></div>${extra}<div class="st-skip">${hint}</div><div class="st-timer" aria-hidden="true"><i></i></div>` };
}
let cur = null;
const SFX = { intro: 'confirm', between: 'move', boss: 'locked', ending: 'pick' };
function show(card = {}, onDone) {
cancel();
if (!document.body) return false;
const ls = lines(card); if (!ls.length) return false;
let el;
try {
const m = markup(card, ls);
el = document.createElement('div');
el.id = 'story-screen'; el.className = 'st-' + card.kind; el.setAttribute('role', 'dialog');
el.setAttribute('aria-label', card.kind === 'between' ? 'Cerita Arcade: lawan berikutnya. Klik atau tekan Spasi untuk lanjut.' : 'Cerita Arcade. Klik atau tekan Spasi untuk lanjut.');
el.style.setProperty('--c', m.color);
el.innerHTML = m.html;
document.body.append(el);
} catch (e) { console.error(e); el?.remove(); return false; }
const ms = duration(card.kind, ls), c = cur = { el, onDone, t0: performance.now(), done: false, timers: [], kind: card.kind };
el.style.setProperty('--st-run', (ms - FADE) + 'ms');
c.timers.push(setTimeout(() => finish('time'), ms - FADE));
el.addEventListener('pointerdown', e => { e.preventDefault(); skip(); });
window.addEventListener('keydown', onKey, true);
try { window.__game?.menuSound?.(SFX[card.kind] || 'move'); } catch (_) {}
return true;
}
// Selama kartu tampil semua tombol ditahan agar tidak bocor ke menu/game di bawahnya (Spasi/Enter tidak boleh memilih ulang karakter, Esc tidak membuka jeda).
function onKey(e) {
if (!cur || cur.done) return;
e.stopImmediatePropagation(); e.preventDefault();
if (e.repeat) return;
if (e.key === ' ' || e.key === 'Enter' || e.key === 'Escape') skip();
}
function release() { const c = cur; if (!c) return null; window.removeEventListener('keydown', onKey, true); for (const t of c.timers) clearTimeout(t); c.timers = []; return c; }
function finish(reason) {
const c = cur; if (!c || c.done) return false;
c.done = true; release(); c.el.classList.add('out'); c.reason = reason;
setTimeout(() => c.el.remove(), FADE + 40);
try { c.onDone?.(reason); } catch (e) { console.error(e); }
return true;
}
function skip() { if (!cur || cur.done || performance.now() - cur.t0 < MIN_SKIP) return false; return finish('skip'); }
// Membatalkan kartu tanpa onDone (mis. pertandingan dibatalkan); kartu yang sedang pudar tetap dibersihkan.
function cancel() { const c = release(); if (!c) return; c.done = true; c.el.remove(); cur = null; }
window.Story = { show, skip, cancel, lines, decorateVs, shadowFighter, bossQuote, get title() { return title(); }, get active() { return !!cur && !cur.done; }, force() { if (cur) { cur.t0 = -1e9; return skip(); } return false; } };
})();

16
vs.js
View File

@ -102,15 +102,17 @@
return Array.from({ length: n }, (_, i) => `<i style="--x:${(rnd() * 100).toFixed(1)}%;--d:${(rnd() * 1.4).toFixed(2)}s;--t:${(2.6 + rnd() * 2.4).toFixed(2)}s;--r:${Math.round(rnd() * 720 - 360)}deg;--w:${6 + Math.round(rnd() * 6)}px;--c:${i % 3 === 0 ? color : CONFETTI[i % CONFETTI.length]}"></i>`).join('');
}
function view(r, ctx = {}) {
const run = r.run || null, two = !!r.p2, playerWon = r.winner === 'player';
const wid = playerWon ? r.player : r.enemy, lid = playerWon ? r.enemy : r.player, Wf = fighter(wid), Lf = fighter(lid);
// run.boss (RENCANA 3.6): 'win' = Bayangan dikalahkan, 'lose' = kalah dari Bayangan (Arcade tetap tamat, jadi pemain tetap tampil sebagai pemenang).
// Bayangan (salinan gelap, id sama dengan pemain) selalu tampil di sisi yang kalah dengan nama dan ucapan khasnya (story.js).
const run = r.run || null, two = !!r.p2, boss = run?.boss || null, playerWon = r.winner === 'player' || boss === 'lose';
const wid = playerWon ? r.player : r.enemy, lid = playerWon ? r.enemy : r.player, Wf = fighter(wid), Lf = boss && window.Story?.shadowFighter ? window.Story.shadowFighter(fighter(lid)) : fighter(lid);
const tone = run ? (run.cleared ? 'win' : 'lose') : (two || playerWon ? 'win' : 'lose');
const q = r._q || (r._q = { win: quote(wid, 'win'), lose: quote(lid, 'lose') });
const q = r._q || (r._q = { win: quote(wid, 'win'), lose: boss ? (window.Story?.bossQuote?.(boss, r.player) || quote(lid, 'lose')) : quote(lid, 'lose') });
const lv = LEVELS[r.level] || '', st = window.MatchRules?.stages?.[r.stage], stName = st ? st.name : '';
let title, kicker, scoreHtml;
if (run) {
title = run.cleared ? 'TAMAT!' : 'BERAKHIR';
kicker = run.kind === 'arcade' ? (run.cleared ? 'SEMUA LAWAN DIKALAHKAN' : `KALAH DI LAWAN KE-${run.wins + 1} DARI ${run.total}`) : `SURVIVAL · REKOR ${run.best}`;
kicker = run.kind === 'arcade' ? (run.cleared ? (boss === 'win' ? 'BAYANGAN DIKALAHKAN' : boss === 'lose' ? 'TAMAT · BAYANGAN BELUM TERKALAHKAN' : 'SEMUA LAWAN DIKALAHKAN') : `KALAH DI LAWAN KE-${run.wins + 1} DARI ${run.total}`) : `SURVIVAL · REKOR ${run.best}`;
scoreHtml = `<div class="rs-score run"><b>${run.wins}</b><span>${run.kind === 'arcade' ? `DARI ${run.total} LAWAN` : 'MENANG BERUNTUN'}</span></div>`;
} else {
title = two ? 'MENANG' : playerWon ? 'MENANG' : 'KALAH';
@ -122,16 +124,16 @@
const stat = (k, v) => `<div><dt>${k}</dt><dd>${esc(v)}</dd></div>`;
const combo = r.matchBest ?? r.bestCombo ?? 0;
const stats = run
? stat(run.kind === 'arcade' ? 'Lawan' : 'Menang beruntun', run.kind === 'arcade' ? `${run.wins} / ${run.total}` : String(run.wins)) + stat(run.kind === 'arcade' ? 'Tingkat' : 'Rekor', run.kind === 'arcade' ? lv : String(run.best)) + stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde terakhir', `${r.playerWins} – ${r.enemyWins}`)
? stat(run.kind === 'arcade' ? 'Lawan' : 'Menang beruntun', run.kind === 'arcade' ? `${run.wins} / ${run.total}` : String(run.wins)) + (boss ? stat('Bayangan', boss === 'win' ? 'Dikalahkan' : 'Bertahan') : stat(run.kind === 'arcade' ? 'Tingkat' : 'Rekor', run.kind === 'arcade' ? lv : String(run.best))) + stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde terakhir', `${r.playerWins} – ${r.enemyWins}`)
: stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde', `${r.playerWins} – ${r.enemyWins}`) + stat('Tingkat', two ? '—' : lv) + stat('Arena', st ? (st.short || stName) : '—');
const html = `<div class="rs" data-tone="${tone}" style="--cw:${esc(Wf.color)};--cl:${esc(Lf.color)}">`
+ (tone === 'win' ? `<div class="rs-confetti" aria-hidden="true">${confetti(Wf.color, 26)}</div>` : '')
+ `<div class="rs-fighter win"><div class="rs-glow"></div><img class="rs-art" src="${esc(artOf(Wf))}" alt="${esc(Wf.name)}" draggable="false"><div class="rs-plate"><small>PEMENANG</small><strong>${esc(Wf.name)}</strong></div></div>`
+ `<div class="rs-fighter win"><div class="rs-glow"></div><img class="rs-art" src="${esc(artOf(Wf))}" alt="${esc(Wf.name)}" draggable="false"><div class="rs-plate"><small>${boss === 'win' ? esc(window.Story?.title || 'PEMENANG') : 'PEMENANG'}</small><strong>${esc(Wf.name)}</strong></div></div>`
+ `<div class="rs-main"><div class="rs-head"><small class="rs-kicker">${esc(kicker)}</small><h1>${title}</h1>${scoreHtml}</div>`
+ `<blockquote class="rs-quote"><p>&ldquo;${esc(q.win)}&rdquo;</p><cite>${esc(Wf.name)}</cite></blockquote>`
+ `<div class="rs-cols">${coins}<section class="rs-stats" aria-label="Statistik"><h3>STATISTIK</h3><dl>${stats}</dl></section></div>`
+ `<div class="rs-actions"><button class="menu-primary rs-again" data-cmd="rematch">MAIN LAGI <b>↗</b></button><button class="result-secondary" data-cmd="reselect">PILIH ULANG</button><button class="result-secondary" data-cmd="shop">KE TOKO</button><button class="result-secondary" data-cmd="home">MENU UTAMA</button></div></div>`
+ `<div class="rs-fighter lose"><img class="rs-art" src="${esc(artOf(Lf))}" alt="${esc(Lf.name)}" draggable="false"><div class="rs-plate"><small>${esc(Lf.name)}</small><p>&ldquo;${esc(q.lose)}&rdquo;</p></div></div></div>`;
+ `<div class="rs-fighter lose${Lf.shadow ? ' shadow' : ''}"><img class="rs-art" src="${esc(artOf(Lf))}" alt="${esc(Lf.name)}" draggable="false"><div class="rs-plate"><small>${esc(Lf.name)}</small><p>&ldquo;${esc(q.lose)}&rdquo;</p></div></div></div>`;
return { title: run ? (run.kind === 'arcade' ? 'MODE ARCADE' : 'MODE SURVIVAL') : 'PERTANDINGAN SELESAI', eyebrow: stName ? stName.toUpperCase() : 'KONOHA FIGHTING', html };
}
// Setelah html dipasang: hitung naik angka koin dan fokuskan wadah layar hasil (kecuali layar sentuh).