Dialog pasangan di layar VS (21 pasangan, balon ucapan kiri-kanan) dan easter egg berpasangan: empat dialog rahasia berlabel DIALOG RAHASIA, +75 koin sekali per pasangan, pencapaian REUNI; layar VS ber-dialog tampil 3,3 dtk; uji tests/t3-dialog.mjs dan penyesuaian tunggu VS di t2-vsresult
This commit is contained in:
parent
4ce380873d
commit
f24f7bccd9
|
|
@ -0,0 +1,41 @@
|
||||||
|
/* Dialog pasangan di layar VS (RENCANA 3.1) dan dialog rahasia berpasangan (RENCANA 3.4).
|
||||||
|
window.DIALOGS['<idA>|<idB>'] = { a, b }: kunci berurutan alfabet (idA < idB); a = ucapan karakter idA, b = ucapan karakter idB.
|
||||||
|
Ada untuk semua 21 pasangan dari 7 karakter; vs.js memilih sisi yang benar (siapa pemain, siapa lawan) dan memakainya sebagai ganti
|
||||||
|
kutipan pembuka umum (content/quotes.js tetap jadi cadangan, mis. untuk karakter yang melawan dirinya sendiri).
|
||||||
|
window.DIALOGS.rahasia = { '<idA>|<idB>': { a, b } }: dialog rahasia untuk empat pasangan tersembunyi. Bila pasangan itu bertemu, vs.js
|
||||||
|
menampilkannya (dengan label DIALOG RAHASIA) menggantikan dialog biasa; meta.js memberi koin dan toast sekali per pasangan dan pencapaian
|
||||||
|
REUNI setelah keempatnya ditemukan. Jangan menyebut pasangan mana di daftar pencapaian atau di teks lain.
|
||||||
|
Pedoman isi: plesetan ringan terhadap citra publik yang sudah menjadi bahan lelucon umum. Tanpa tuduhan fakta atau skandal nyata,
|
||||||
|
tanpa unsur agama, tanpa merendahkan pribadi atau keluarga, tanpa ujaran kebencian. Gufron hanya meme ringan (semut, syududu, koloni).
|
||||||
|
Tiap baris maksimal sekitar 60 karakter agar muat di balon ucapan pada layar setinggi 390 px. */
|
||||||
|
window.DIALOGS = window.DIALOGS || {};
|
||||||
|
Object.assign(window.DIALOGS, {
|
||||||
|
'anice|browo': { a: 'Izinkan saya uraikan jogetmu dalam tiga poin.', b: 'Poin satu sampai tiga: goyang! Tidak perlu narasi.' },
|
||||||
|
'anice|ethanolman': { a: 'Menurut hitungan saya, tangkimu punya tiga masalah.', b: 'Hahaha! Tiga masalah, satu solusi: tambah etanol!' },
|
||||||
|
'anice|gufron': { a: 'Saya siapkan pidato. Semutmu boleh ikut menyimak.', b: 'Syududu! Kata koloni, pidatomu kepanjangan.' },
|
||||||
|
'anice|megachan': { a: 'Dengan hormat, izinkan saya menyampaikan satu kalimat.', b: 'Satu kalimat? Orasi banteng sepuluh menit tanpa jeda!' },
|
||||||
|
'anice|soloman': { a: 'Sederhana itu bagus. Mari kita rinci jadi tiga bagian.', b: 'Rincinya nanti. Sekarang kita lihat langsung ke lapangan.' },
|
||||||
|
'anice|sonofsolo': { a: 'Pertanyaanmu menarik. Boleh saya jawab dengan narasi?', b: 'Boleh. Tapi jawabannya tetap asam sulfat.' },
|
||||||
|
'browo|ethanolman': { a: 'Sawit punyaku, etanol punyamu. Sama-sama hijau!', b: 'Hahaha! Sawitmu, etanolku. Tangkinya sama-sama penuh!' },
|
||||||
|
'browo|gufron': { a: 'Semutmu baris rapi sekali. Mau ikut jogetku?', b: 'Syududu! Semutku mau naik ke topimu. Boleh?' },
|
||||||
|
'browo|megachan': { a: 'Joget dulu sebelum berantem, yuk. Sebentar saja!', b: 'Banteng tidak joget. Banteng menyeruduk!' },
|
||||||
|
'browo|soloman': { a: 'Safari ketemu blusukan. Sama-sama keliling, beda topi.', b: 'Ya, sederhana saja. Saya kibas sarung, urusan selesai.' },
|
||||||
|
'browo|sonofsolo': { a: 'Tatapanmu tenang sekali. Lagi mikirin joget juga?', b: 'Bukan. Saya sedang menyiapkan pertanyaan jebakan.' },
|
||||||
|
'ethanolman|gufron': { a: 'Hahaha! Semutmu boleh numpang di tangkiku, gratis.', b: 'Syududu! Tangkimu bocor, semutku sudah masuk.' },
|
||||||
|
'ethanolman|megachan': { a: 'Hahaha! Bahan bakar banteng itu rumput atau etanol?', b: 'Rumput! Tapi kepalan ini bisa isi ulang kapan saja.' },
|
||||||
|
'ethanolman|soloman': { a: 'Hahaha! Esemka isi etanol? Hemat dan hijau!', b: 'Boleh saja. Yang penting mesinnya jalan. Sederhana.' },
|
||||||
|
'ethanolman|sonofsolo': { a: 'Hahaha! Labumu isi apa? Asam sulfat atau etanol?', b: 'Pertanyaan bagus. Berapa oktan tawamu?' },
|
||||||
|
'gufron|megachan': { a: 'Syududu! Banteng besar, semut kecil. Seimbang, kan?', b: 'Jangan merayap di kakiku. Banteng itu geli!' },
|
||||||
|
'gufron|soloman': { a: 'Syududu! Semutku boleh ikut konvoi Esemka?', b: 'Boleh. Naik saja di kap, tanpa tiket. Sederhana.' },
|
||||||
|
'gufron|sonofsolo': { a: 'Semut-semutku penasaran sama labumu. Syududu!', b: 'Jangan dekat-dekat. Isinya bukan gula.' },
|
||||||
|
'megachan|soloman': { a: 'Banteng sudah panas. Kamu bawa apa selain sarung?', b: 'Cuma Esemka. Mesinnya dijamin jalan. Mungkin.' },
|
||||||
|
'megachan|sonofsolo': { a: 'Tatapanmu tenang sekali. Banteng jadi curiga.', b: 'Tenang itu strategi. Seperti pertanyaan jebakan saya.' },
|
||||||
|
'soloman|sonofsolo': { a: 'Kita satu nama depan. Kebetulan yang sederhana, ya.', b: 'Kebetulan yang sangat tenang. Boleh saya bertanya?' },
|
||||||
|
// Dialog rahasia: empat pasangan tersembunyi (data terpisah dari dialog biasa).
|
||||||
|
rahasia: {
|
||||||
|
'soloman|sonofsolo': { a: 'Dua Solo di satu arena. Kotanya cukup, kan?', b: 'Cukup. Satu pertanyaan saja: Esemkamu isi asam sulfat?' },
|
||||||
|
'browo|soloman': { a: 'Safari bertemu blusukan! Joget sebentar dulu, yuk?', b: 'Boleh. Tapi jogetnya yang sederhana, satu langkah saja.' },
|
||||||
|
'anice|megachan': { a: 'Narasi melawan orasi. Siapa yang lebih lama bicara?', b: 'Banteng tidak butuh narasi. Cukup satu seruduk!' },
|
||||||
|
'ethanolman|gufron': { a: 'Hahaha! Etanol dari tebu itu manis. Semutmu suka?', b: 'Syududu! Koloni sudah mengantre di pabrik gulanya!' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -19,6 +19,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
|
||||||
- `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`.
|
- `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`.
|
||||||
- Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache).
|
- Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache).
|
||||||
- `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.
|
- `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}` 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` (kiri muncul dulu, kanan membalas) 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`; `tests/t3-dialog.mjs` (env `DLG_SHOTS`, `DLG_ONLY=data|map|fit|secret|flow|time|shots`) memeriksa data, pemetaan sisi, muat di 568x320 sampai 1280x720 untuk 42 kombinasi, alur egg, dan waktu tampil. 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']`.
|
- 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']`.
|
||||||
- `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter.
|
- `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.
|
- `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang.
|
||||||
|
|
@ -29,7 +30,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
|
||||||
`__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot.
|
`__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot.
|
||||||
|
|
||||||
## Alat uji headless
|
## Alat uji headless
|
||||||
`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-hud.mjs` (env `HUD_ONLY=meter|enemy|cpu|pause|layout`) menguji meter ultimate dan menu jeda; `tests/t2-vsresult.mjs` (env `VSR_SHOTS`, `VSR_ONLY=quotes|vs|skip|abort|result|run|ui|focus|phone`) menguji layar VS, layar hasil, dan kutipan. `tests/t2-brand.mjs` (env `BRAND_SHOTS`, `BRAND_ONLY=text|quote|log|menu|flow|docs`) menguji sisa nama game asal di seluruh layar, kutipan loading, Yang baru dan toast, serta latar kolase.
|
`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-hud.mjs` (env `HUD_ONLY=meter|enemy|cpu|pause|layout`) menguji meter ultimate dan menu jeda; `tests/t2-vsresult.mjs` (env `VSR_SHOTS`, `VSR_ONLY=quotes|vs|skip|abort|result|run|ui|focus|phone`) menguji layar VS, layar hasil, dan kutipan. `tests/t3-dialog.mjs` (dialog pasangan dan easter egg, lihat butir Dialog pasangan). `tests/t2-brand.mjs` (env `BRAND_SHOTS`, `BRAND_ONLY=text|quote|log|menu|flow|docs`) menguji sisa nama game asal di seluruh layar, kutipan loading, Yang baru dan toast, serta latar kolase.
|
||||||
|
|
||||||
## Aturan kerja
|
## Aturan kerja
|
||||||
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).
|
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,6 @@
|
||||||
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div>
|
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div>
|
||||||
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) S · Menunduk<br>SPACE · Kombo 3 pukulan ↓ ↓ + SPACE · Uppercut ↓ → + SPACE · Tinju maju<br>I / O · Skill P · Ultimate K · Jurus toko<br>L · Blok (ketuk = parry) J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak ↑ · Lompat N · Serang M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
|
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) S · Menunduk<br>SPACE · Kombo 3 pukulan ↓ ↓ + SPACE · Uppercut ↓ → + SPACE · Tinju maju<br>I / O · Skill P · Ultimate K · Jurus toko<br>L · Blok (ketuk = parry) J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak ↑ · Lompat N · Serang M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
|
||||||
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
|
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
|
||||||
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="content/quotes.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
|
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
16
meta.js
16
meta.js
|
|
@ -58,7 +58,8 @@
|
||||||
arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'],
|
arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'],
|
||||||
survival5: ['BERTAHAN', 'Menang 5 kali berturut-turut di Survival.'],
|
survival5: ['BERTAHAN', 'Menang 5 kali berturut-turut di Survival.'],
|
||||||
twoplayer: ['TEMAN BERMAIN', 'Mainkan satu pertandingan dua pemain.'],
|
twoplayer: ['TEMAN BERMAIN', 'Mainkan satu pertandingan dua pemain.'],
|
||||||
special: ['SPESIALIS', 'Beli satu varian skill karakter.']
|
special: ['SPESIALIS', 'Beli satu varian skill karakter.'],
|
||||||
|
reuni: ['REUNI', 'Temukan semua dialog rahasia.']
|
||||||
};
|
};
|
||||||
const blank = () => ({ v: 1, coins: 0, earned: 0, voice: true, owned: { skin: ['standar'], move: [], alt: [] }, skin: {}, move: null, alt: {}, best: { survival: 0 }, stats: { wins: 0, losses: 0, bestCombo: 0, perfects: 0 }, ach: {}, eggs: {} });
|
const blank = () => ({ v: 1, coins: 0, earned: 0, voice: true, owned: { skin: ['standar'], move: [], alt: [] }, skin: {}, move: null, alt: {}, best: { survival: 0 }, stats: { wins: 0, losses: 0, bestCombo: 0, perfects: 0 }, ach: {}, eggs: {} });
|
||||||
let save = blank();
|
let save = blank();
|
||||||
|
|
@ -219,7 +220,7 @@
|
||||||
}
|
}
|
||||||
function renderBook() {
|
function renderBook() {
|
||||||
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
|
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
|
||||||
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'logo'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
|
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'logo', 'reuni'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
|
||||||
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
||||||
}
|
}
|
||||||
function onClick(e) {
|
function onClick(e) {
|
||||||
|
|
@ -256,6 +257,15 @@
|
||||||
function egg(id, label, fn) {
|
function egg(id, label, fn) {
|
||||||
fn(); if (!save.eggs[id]) { save.eggs[id] = 1; addCoins(100); toast(label + ' +100 koin', 'ach'); } if (ACH[id]) unlock(id);
|
fn(); if (!save.eggs[id]) { save.eggs[id] = 1; addCoins(100); toast(label + ' +100 koin', 'ach'); } if (ACH[id]) unlock(id);
|
||||||
}
|
}
|
||||||
|
// Easter egg berpasangan (RENCANA 3.4): layar VS memanggil pairEgg(kunci) saat dialog rahasia pasangan itu tampil (daftar pasangan: DIALOGS.rahasia di content/dialogs.js).
|
||||||
|
// Koin dan toast hanya sekali per pasangan (save.eggs['dialog:<kunci>']); pencapaian REUNI terbuka setelah semuanya ditemukan. Mengembalikan true bila baru ditemukan.
|
||||||
|
const PAIR_COINS = 75, pairKeys = () => Object.keys(window.DIALOGS?.rahasia || {});
|
||||||
|
function pairEgg(key) {
|
||||||
|
const k = 'dialog:' + key; if (!pairKeys().includes(key) || save.eggs[k]) return false;
|
||||||
|
save.eggs[k] = 1; addCoins(PAIR_COINS); toast('DIALOG RAHASIA TERBUKA +' + PAIR_COINS + ' koin', 'ach');
|
||||||
|
if (pairKeys().every(x => save.eggs['dialog:' + x])) unlock('reuni');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const CODES = {
|
const CODES = {
|
||||||
syududu: () => egg('syududu', 'SYUDUDU!', () => swarm('🐜', 40, 'ants')),
|
syududu: () => egg('syududu', 'SYUDUDU!', () => swarm('🐜', 40, 'ants')),
|
||||||
mbg: () => egg('mbg', 'TRUK KATERING LEWAT', () => swarm('🚚', 1, 'truck')),
|
mbg: () => egg('mbg', 'TRUK KATERING LEWAT', () => swarm('🚚', 1, 'truck')),
|
||||||
|
|
@ -296,5 +306,5 @@
|
||||||
}
|
}
|
||||||
mountCheatRow();
|
mountCheatRow();
|
||||||
|
|
||||||
window.Meta = { get lastAward() { return lastAward; }, redeem, shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } };
|
window.Meta = { get lastAward() { return lastAward; }, redeem, pairEgg, shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export default async ({ page, game, errors, characters }) => {
|
||||||
const dir = process.env.VSR_SHOTS || path.join(os.tmpdir(), 't2-vsresult-shots'); fs.mkdirSync(dir, { recursive: true });
|
const dir = process.env.VSR_SHOTS || path.join(os.tmpdir(), 't2-vsresult-shots'); fs.mkdirSync(dir, { recursive: true });
|
||||||
const shot = async name => { await page.screenshot({ path: path.join(dir, name + '.png') }); };
|
const shot = async name => { await page.screenshot({ path: path.join(dir, name + '.png') }); };
|
||||||
const wait = ms => page.waitForTimeout(ms);
|
const wait = ms => page.waitForTimeout(ms);
|
||||||
|
// Layar VS dengan dialog pasangan (Tahap 3.1) tampil lebih lama (VS.durationDialog); tunggu sampai benar-benar selesai.
|
||||||
|
const vsEnd = () => page.waitForFunction(() => !window.__game.vsActive(), null, { timeout: 9000 }).catch(() => null);
|
||||||
// Menyelesaikan pertandingan: sisi 'player' atau 'enemy' menang dua ronde (tanpa uji gerak).
|
// Menyelesaikan pertandingan: sisi 'player' atau 'enemy' menang dua ronde (tanpa uji gerak).
|
||||||
const finish = (side, rounds = 2) => game(({ side, rounds }) => {
|
const finish = (side, rounds = 2) => game(({ side, rounds }) => {
|
||||||
const g = window.__game; g.manual = true; let won = 0;
|
const g = window.__game; g.manual = true; let won = 0;
|
||||||
|
|
@ -68,8 +70,9 @@ export default async ({ page, game, errors, characters }) => {
|
||||||
const r = await startVS(p, e, { stage: 'harbor', level: 'hard' });
|
const r = await startVS(p, e, { stage: 'harbor', level: 'hard' });
|
||||||
s = await vsState();
|
s = await vsState();
|
||||||
ok(`vs ${p} lawan ${e}: layar tampil, ronde ditahan`, r && s.el && s.active && s.phase === 'intro' && s.phaseTime === 0 && s.cues.length === 0 && !s.roundCard, s);
|
ok(`vs ${p} lawan ${e}: layar tampil, ronde ditahan`, r && s.el && s.active && s.phase === 'intro' && s.phaseTime === 0 && s.cues.length === 0 && !s.roundCard, s);
|
||||||
const names = await game(({ p, e }) => ({ p: window.FIGHTERS?.[p]?.name || 'BRO WO', e: window.FIGHTERS?.[e]?.name || 'BRO WO', q: window.QUOTES }), { p, e });
|
const names = await game(({ p, e }) => ({ p: window.FIGHTERS?.[p]?.name || 'BRO WO', e: window.FIGHTERS?.[e]?.name || 'BRO WO', q: window.QUOTES, d: window.VS.dialog(p, e) }), { p, e });
|
||||||
ok(`vs ${p} lawan ${e}: nama dan kutipan pembuka dari QUOTES`, s.names[0] === names.p && s.names[1] === names.e && names.q[p].intro.some(t => s.quotes[0].includes(t)) && names.q[e].intro.some(t => s.quotes[1].includes(t)), { s, names: [names.p, names.e] });
|
// Tahap 3.1: pasangan yang punya dialog menampilkan dua balon ucapan (ganti kutipan); tanpa dialog kutipan pembuka QUOTES jadi cadangan.
|
||||||
|
ok(`vs ${p} lawan ${e}: nama dan ucapan pembuka (dialog pasangan, cadangan QUOTES)`, s.names[0] === names.p && s.names[1] === names.e && (names.d ? s.quotes[0] === names.d.l && s.quotes[1] === names.d.r : names.q[p].intro.some(t => s.quotes[0].includes(t)) && names.q[e].intro.some(t => s.quotes[1].includes(t))), { s, names: [names.p, names.e] });
|
||||||
const adv = await game(() => { const g = window.__game; g.manual = true; g.advance(1.5); return { phaseTime: g.snapshot().match.phaseTime, cues: g.snapshot().roundCues.length }; });
|
const adv = await game(() => { const g = window.__game; g.manual = true; g.advance(1.5); return { phaseTime: g.snapshot().match.phaseTime, cues: g.snapshot().roundCues.length }; });
|
||||||
ok(`vs ${p} lawan ${e}: simulasi tidak berjalan selama layar VS`, adv.phaseTime === 0 && adv.cues === 0, adv);
|
ok(`vs ${p} lawan ${e}: simulasi tidak berjalan selama layar VS`, adv.phaseTime === 0 && adv.cues === 0, adv);
|
||||||
if (p === 'browo') { await game(() => { window.__game.manual = false; }); await wait(300); await shot('vs-1280x520-a-masuk'); await wait(700); await shot('vs-1280x520-b-slam'); await wait(600); await shot('vs-1280x520-c-akhir'); }
|
if (p === 'browo') { await game(() => { window.__game.manual = false; }); await wait(300); await shot('vs-1280x520-a-masuk'); await wait(700); await shot('vs-1280x520-b-slam'); await wait(600); await shot('vs-1280x520-c-akhir'); }
|
||||||
|
|
@ -79,7 +82,7 @@ export default async ({ page, game, errors, characters }) => {
|
||||||
// waktu: kira-kira 2,2 detik lalu cue Round 1 muncul tepat sekali dan urutan intro normal
|
// waktu: kira-kira 2,2 detik lalu cue Round 1 muncul tepat sekali dan urutan intro normal
|
||||||
const t0 = Date.now(); await startVS('gufron', 'anice');
|
const t0 = Date.now(); await startVS('gufron', 'anice');
|
||||||
let first = null; while (Date.now() - t0 < 4000) { s = await vsState(); if (!s.active) { first = Date.now() - t0; break; } await wait(40); }
|
let first = null; while (Date.now() - t0 < 4000) { s = await vsState(); if (!s.active) { first = Date.now() - t0; break; } await wait(40); }
|
||||||
ok('vs: selesai sendiri dalam 1,8 - 2,6 detik', first !== null && first > 1800 && first < 2600, { first });
|
ok('vs: selesai sendiri dalam 2,8 - 3,8 detik (pasangan ber-dialog menambah waktu baca)', first !== null && first > 2800 && first < 3800, { first });
|
||||||
s = await vsState();
|
s = await vsState();
|
||||||
ok('vs: cue Round 1 keluar tepat sekali setelah layar selesai', s.cues.join() === 'round1', s);
|
ok('vs: cue Round 1 keluar tepat sekali setelah layar selesai', s.cues.join() === 'round1', s);
|
||||||
await wait(500); s = await vsState();
|
await wait(500); s = await vsState();
|
||||||
|
|
@ -96,7 +99,7 @@ export default async ({ page, game, errors, characters }) => {
|
||||||
await startVS('anice', 'soloman'); await wait(150);
|
await startVS('anice', 'soloman'); await wait(150);
|
||||||
const calm = await game(() => { const art = document.querySelector('#vs-screen .vs-art'), q = document.querySelector('#vs-screen .vs-quote'); return { anim: getComputedStyle(art).animationName, qop: getComputedStyle(q).opacity, active: window.__game.vsActive() }; });
|
const calm = await game(() => { const art = document.querySelector('#vs-screen .vs-art'), q = document.querySelector('#vs-screen .vs-quote'); return { anim: getComputedStyle(art).animationName, qop: getComputedStyle(q).opacity, active: window.__game.vsActive() }; });
|
||||||
ok('gerak dikurangi: tanpa animasi masuk, kutipan langsung terlihat', calm.anim === 'none' && calm.qop === '1' && calm.active, calm);
|
ok('gerak dikurangi: tanpa animasi masuk, kutipan langsung terlihat', calm.anim === 'none' && calm.qop === '1' && calm.active, calm);
|
||||||
await wait(2200); const after = await vsState();
|
await vsEnd(); await wait(60); const after = await vsState();
|
||||||
ok('gerak dikurangi: layar tetap selesai dan cue Round 1 keluar', !after.active && after.cues.join() === 'round1', after);
|
ok('gerak dikurangi: layar tetap selesai dan cue Round 1 keluar', !after.active && after.cues.join() === 'round1', after);
|
||||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +125,7 @@ export default async ({ page, game, errors, characters }) => {
|
||||||
await page.keyboard.down('d'); await page.keyboard.press('i'); await page.keyboard.up('d');
|
await page.keyboard.down('d'); await page.keyboard.press('i'); await page.keyboard.up('d');
|
||||||
const keys = await game(() => ({ x: Math.round(window.__game.hero.x), cd: window.__game.hero.cooldowns.skill1 }));
|
const keys = await game(() => ({ x: Math.round(window.__game.hero.x), cd: window.__game.hero.cooldowns.skill1 }));
|
||||||
ok('tombol gerak/skill tidak bocor saat VS', keys.x === 350 && keys.cd === 0, keys);
|
ok('tombol gerak/skill tidak bocor saat VS', keys.x === 350 && keys.cd === 0, keys);
|
||||||
await wait(2300);
|
await vsEnd(); await wait(350);
|
||||||
}
|
}
|
||||||
if (want('abort')) {
|
if (want('abort')) {
|
||||||
// kembali ke menu saat VS: overlay hilang, tidak ada cue susulan
|
// kembali ke menu saat VS: overlay hilang, tidak ada cue susulan
|
||||||
|
|
@ -136,10 +139,10 @@ export default async ({ page, game, errors, characters }) => {
|
||||||
await startVS('browo', 'anice'); await wait(300); await startVS('gufron', 'megachan'); await wait(100);
|
await startVS('browo', 'anice'); await wait(300); await startVS('gufron', 'megachan'); await wait(100);
|
||||||
s = await vsState(); const n = await game(() => document.querySelectorAll('#vs-screen').length);
|
s = await vsState(); const n = await game(() => document.querySelectorAll('#vs-screen').length);
|
||||||
ok('abort: pertandingan baru saat VS menggantikan layar lama', n === 1 && s.names[0] === 'GUFRON' && s.active, { s, n });
|
ok('abort: pertandingan baru saat VS menggantikan layar lama', n === 1 && s.names[0] === 'GUFRON' && s.active, { s, n });
|
||||||
await wait(2300); s = await vsState();
|
await vsEnd(); await wait(350); s = await vsState();
|
||||||
ok('abort: hanya satu cue Round 1 untuk pertandingan terbaru', s.cues.join() === 'round1' && !s.el, s);
|
ok('abort: hanya satu cue Round 1 untuk pertandingan terbaru', s.cues.join() === 'round1' && !s.el, s);
|
||||||
// announcer tidak ditahan: clip round_1 diminta setelah VS (bukan sebelum)
|
// announcer tidak ditahan: clip round_1 diminta setelah VS (bukan sebelum)
|
||||||
const ann = await game(async () => { const g = window.__game; g.manual = false; const log = []; g.onRoundCue(e => log.push(e.cue + e.round)); g.startMatch({ player: 'browo', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'versus', vs: true }); const t = performance.now(); await new Promise(r => setTimeout(r, 900)); const early = log.length; await new Promise(r => setTimeout(r, 1700)); const late = log.slice(); g.onRoundCue(null); return { early, late, t: Math.round(performance.now() - t) }; });
|
const ann = await game(async () => { const g = window.__game; g.manual = false; const log = []; g.onRoundCue(e => log.push(e.cue + e.round)); g.startMatch({ player: 'browo', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'versus', vs: true }); const t = performance.now(); await new Promise(r => setTimeout(r, 900)); const early = log.length; await new Promise(r => setTimeout(r, 2700)); const late = log.slice(); g.onRoundCue(null); return { early, late, t: Math.round(performance.now() - t) }; });
|
||||||
ok('onRoundCue: tidak terpanggil selama VS, sekali sesudahnya', ann.early === 0 && ann.late.join() === 'round1', ann);
|
ok('onRoundCue: tidak terpanggil selama VS, sekali sesudahnya', ann.early === 0 && ann.late.join() === 'round1', ann);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +246,7 @@ export default async ({ page, game, errors, characters }) => {
|
||||||
ok('dua pemain lewat menu: layar VS berlabel PEMAIN 1 / PEMAIN 2', s.active && meta.who.join() === 'PEMAIN 1,PEMAIN 2' && /DUA PEMAIN/.test(meta.top), { s, meta });
|
ok('dua pemain lewat menu: layar VS berlabel PEMAIN 1 / PEMAIN 2', s.active && meta.who.join() === 'PEMAIN 1,PEMAIN 2' && /DUA PEMAIN/.test(meta.top), { s, meta });
|
||||||
const tDuring = await toasts();
|
const tDuring = await toasts();
|
||||||
ok('dua pemain: toast petunjuk P2 dan pencapaian ditunda, tidak menutupi bilah VS', tDuring.length === 0, tDuring);
|
ok('dua pemain: toast petunjuk P2 dan pencapaian ditunda, tidak menutupi bilah VS', tDuring.length === 0, tDuring);
|
||||||
await wait(2200); s = await vsState();
|
await vsEnd(); await wait(350); s = await vsState();
|
||||||
const tAfter = await toasts();
|
const tAfter = await toasts();
|
||||||
ok('dua pemain: petunjuk P2 dan pencapaian TEMAN BERMAIN muncul tepat setelah VS selesai', tAfter.some(t => /^P2:/.test(t.text)) && tAfter.some(t => /TEMAN BERMAIN/.test(t.text)) && tAfter.every(t => !t.coversVsTop), tAfter);
|
ok('dua pemain: petunjuk P2 dan pencapaian TEMAN BERMAIN muncul tepat setelah VS selesai', tAfter.some(t => /^P2:/.test(t.text)) && tAfter.some(t => /TEMAN BERMAIN/.test(t.text)) && tAfter.every(t => !t.coversVsTop), tAfter);
|
||||||
ok('dua pemain: ronde mulai normal setelah VS', !s.active && s.cues.join() === 'round1', s);
|
ok('dua pemain: ronde mulai normal setelah VS', !s.active && s.cues.join() === 'round1', s);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
// Tahap 3.1 dan 3.4: dialog pasangan di layar VS dan easter egg berpasangan (dialog rahasia, +75 koin, pencapaian REUNI).
|
||||||
|
// node tests/harness.mjs tests/t3-dialog.mjs
|
||||||
|
// DLG_SHOTS=folder simpan screenshot (default: folder sementara sistem); DLG_ONLY=regex hanya bagian yang namanya cocok (data|map|fit|secret|flow|time|shots)
|
||||||
|
// Jendela awal 1280x520: tampilan harus muat di tinggi 520 px.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
export const options = { viewport: { width: 1280, height: 520 } };
|
||||||
|
|
||||||
|
export default async ({ page, game, characters }) => {
|
||||||
|
const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, ...(pass ? {} : { info }) });
|
||||||
|
const only = process.env.DLG_ONLY ? new RegExp(process.env.DLG_ONLY) : null, want = n => !only || only.test(n);
|
||||||
|
const dir = process.env.DLG_SHOTS || path.join(os.tmpdir(), 't3-dialog-shots'); fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const wait = ms => page.waitForTimeout(ms);
|
||||||
|
const sorted = [...characters].sort();
|
||||||
|
const keys = []; for (let i = 0; i < sorted.length; i++) for (let j = i + 1; j < sorted.length; j++) keys.push(sorted[i] + '|' + sorted[j]);
|
||||||
|
const startVS = (p, e, vs = true, mode = 'versus') => game(({ p, e, vs, mode }) => { const g = window.__game; g.manual = false; return g.startMatch({ player: p, enemy: e, stage: 'bellora', level: 'medium', mode, vs }); }, { p, e, vs, mode });
|
||||||
|
const vsEnd = () => page.waitForFunction(() => !window.__game.vsActive(), null, { timeout: 9000 }).catch(() => null);
|
||||||
|
const state = () => game(() => {
|
||||||
|
const g = window.__game, s = g.snapshot(), q = sel => [...document.querySelectorAll(sel)];
|
||||||
|
return { active: g.vsActive(), cues: s.roundCues.map(c => c.cue + c.round), bubbles: q('#vs-screen .vs-bubble').map(n => n.textContent), quotes: q('#vs-screen .vs-quote').map(n => n.textContent), secretBubbles: q('#vs-screen .vs-bubble.secret').length, label: q('#vs-screen .vs-secret').map(n => n.textContent.trim()), toasts: q('.meta-toast').map(n => n.textContent), coins: window.Meta.coins, eggs: Object.keys(window.Meta.save.eggs).filter(k => k.startsWith('dialog:')).sort(), ach: !!window.Meta.save.ach.reuni };
|
||||||
|
});
|
||||||
|
const toastsNow = () => game(() => [...document.querySelectorAll('.meta-toast')].map(n => n.textContent));
|
||||||
|
// Toast yang ditahan selama VS dilepas Meta begitu tidak ada layar VS aktif: batalkan VS dulu, tunggu toast tertahan muncul, lalu bersihkan sebelum bagian uji berikutnya.
|
||||||
|
const resetMeta = async () => {
|
||||||
|
await game(() => { window.VS.cancel(); });
|
||||||
|
await wait(380);
|
||||||
|
await game(() => { const M = window.Meta; M.save.eggs = {}; M.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; document.querySelectorAll('.meta-toast').forEach(n => n.remove()); });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 3.1 / 3.4 data ----------
|
||||||
|
if (want('data')) {
|
||||||
|
const D = await game(() => JSON.parse(JSON.stringify(window.DIALOGS || {})));
|
||||||
|
const bad = /allah|tuhan|bismillah|\bamin\b|islam|kristen|hindu|budha|gereja|masjid|ustaz|ustadz|kiai|alhamdulillah|insyaallah|korupsi|tersangka|penjara|ijazah|dinasti|nepotisme|\bsuap\b|skandal|\bayah\b|\bbapak\b|\bibu\b|keluarga|\banak\b|istri|suami|\bpak\b|\bbu\b/i;
|
||||||
|
const lineOk = t => typeof t === 'string' && t.length >= 10 && t.length <= 60 && !bad.test(t);
|
||||||
|
ok('data: tepat 21 kunci pasangan ditambah rahasia, semua berurutan alfabet dan tidak ada kunci lain', Object.keys(D).filter(k => k !== 'rahasia').sort().join() === keys.join() && keys.length === 21, Object.keys(D));
|
||||||
|
ok('data: tiap pasangan punya dua baris a/b, 10-60 karakter, tanpa kata terlarang', keys.every(k => D[k] && lineOk(D[k].a) && lineOk(D[k].b)), keys.filter(k => !(D[k] && lineOk(D[k].a) && lineOk(D[k].b))));
|
||||||
|
const all = keys.flatMap(k => [D[k]?.a, D[k]?.b]);
|
||||||
|
ok('data: 42 baris unik (tidak ada ucapan yang dipakai dua kali)', new Set(all).size === 42, all.length);
|
||||||
|
const meme = /semut|syududu|koloni|remah|gula/i, gufronLines = keys.filter(k => k.includes('gufron')).map(k => k.split('|')[0] === 'gufron' ? D[k].a : D[k].b);
|
||||||
|
ok('data: baris Gufron hanya meme ringan (semut/syududu/koloni/remah/gula)', gufronLines.length === 6 && gufronLines.every(t => meme.test(t)), gufronLines);
|
||||||
|
const R = D.rahasia || {}, rk = Object.keys(R);
|
||||||
|
ok('rahasia: tepat empat pasangan, kuncinya salah satu dari 21 pasangan', rk.length === 4 && rk.every(k => keys.includes(k)), rk);
|
||||||
|
ok('rahasia: dua baris per pasangan, lolos pedoman, berbeda dari dialog biasa pasangan yang sama', rk.every(k => lineOk(R[k].a) && lineOk(R[k].b) && R[k].a !== D[k].a && R[k].b !== D[k].b), rk);
|
||||||
|
ok('rahasia: tidak ada baris yang sama dengan dialog biasa mana pun', rk.every(k => !all.includes(R[k].a) && !all.includes(R[k].b)), rk);
|
||||||
|
const gr = rk.filter(k => k.includes('gufron')).map(k => k.split('|')[0] === 'gufron' ? R[k].a : R[k].b);
|
||||||
|
ok('rahasia: baris Gufron tetap meme ringan', gr.length >= 1 && gr.every(t => meme.test(t)), gr);
|
||||||
|
const ach = await game(() => { const M = window.Meta; M.open('book'); const li = [...document.querySelectorAll('#meta-overlay .ach li')].map(l => l.textContent); M.close(); return li; });
|
||||||
|
const spoiler = /bro wo|mega chan|solo|ethanol|a nice|gufron|browo|megachan|anice|soloman|sonofsolo|ethanolman|\|/i, hiddenRows = ach.filter(t => /\?\?\?/.test(t));
|
||||||
|
ok('pencapaian: 21 pencapaian di daftar (REUNI satu-satunya tambahan)', ach.length === 21, ach.length);
|
||||||
|
ok('pencapaian: tujuh pencapaian rahasia (termasuk REUNI) tampil ??? dan tanpa nama karakter/pasangan', hiddenRows.length === 7 && hiddenRows.every(t => /^☆ \?\?\?Pencapaian rahasia\.$/.test(t) && !spoiler.test(t)), hiddenRows);
|
||||||
|
ok('pencapaian: nama REUNI belum bocor sebelum terbuka', ach.every(t => !/REUNI/.test(t)), ach.filter(t => /REUNI/.test(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- pemetaan sisi ----------
|
||||||
|
if (want('map')) {
|
||||||
|
const out = await game(() => {
|
||||||
|
const D = window.DIALOGS, ids = Object.keys(window.FIGHTERS).concat(['browo']).sort(), bad = [];
|
||||||
|
for (const p of ids) for (const e of ids) {
|
||||||
|
const d = VS.dialog(p, e);
|
||||||
|
if (p === e) { if (d) bad.push(['mirror', p]); continue; }
|
||||||
|
const key = p < e ? p + '|' + e : e + '|' + p, src = D.rahasia[key] || D[key];
|
||||||
|
const wantL = p < e ? src.a : src.b, wantR = p < e ? src.b : src.a;
|
||||||
|
if (!d || d.l !== wantL || d.r !== wantR || d.key !== key || d.secret !== !!D.rahasia[key]) bad.push([p, e, d]);
|
||||||
|
}
|
||||||
|
return { n: ids.length, bad, none: VS.dialog('browo', 'tidakada'), nul: VS.dialog(null, 'browo') };
|
||||||
|
});
|
||||||
|
ok('peta: 42 kombinasi (pemain, lawan) memetakan ucapan ke sisi yang benar, kunci urut alfabet', out.n === 7 && out.bad.length === 0, out.bad.slice(0, 3));
|
||||||
|
ok('peta: karakter yang sama atau tidak dikenal tidak punya dialog (kutipan umum jadi cadangan)', out.none === null && out.nul === null, out);
|
||||||
|
// pemain di kiri, lawan di kanan: balon kiri = ucapan pemain
|
||||||
|
for (const [p, e] of [['soloman', 'browo'], ['browo', 'soloman'], ['gufron', 'anice']]) {
|
||||||
|
await resetMeta(); await startVS(p, e); await wait(120); const s = await state(), exp = await game(({ p, e }) => VS.dialog(p, e), { p, e });
|
||||||
|
ok(`peta layar: ${p} (kiri) lawan ${e} (kanan): balon kiri ${p} dan kanan ${e}`, s.bubbles.length === 2 && s.bubbles[0] === exp.l && s.bubbles[1] === exp.r && s.quotes.length === 2, { s, exp });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- muat di layar (reduced motion: tata letak langsung final) ----------
|
||||||
|
if (want('fit')) {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
const sizes = [[1280, 720], [1280, 520], [1024, 520], [844, 390], [667, 375], [568, 320]], ids = [...characters];
|
||||||
|
for (const [w, h] of sizes) {
|
||||||
|
await page.setViewportSize({ width: w, height: h }); await resetMeta();
|
||||||
|
const bad = [];
|
||||||
|
for (const p of ids) for (const e of ids) {
|
||||||
|
if (p === e) continue;
|
||||||
|
await startVS(p, e);
|
||||||
|
const r = await game(() => {
|
||||||
|
const vw = innerWidth, vh = innerHeight, rect = n => n && n.getBoundingClientRect(), top = rect(document.querySelector('#vs-screen .vs-top')), lab = rect(document.querySelector('#vs-screen .vs-secret'));
|
||||||
|
const limit = Math.max(top.bottom, lab ? lab.bottom : 0), mark = rect(document.querySelector('#vs-screen .vs-mark b')), res = [];
|
||||||
|
for (const [i, b] of [...document.querySelectorAll('#vs-screen .vs-bubble')].entries()) {
|
||||||
|
const br = rect(b), info = b.closest('.vs-info'), ir = rect(info), nm = rect(info.querySelector('.vs-name')), skip = rect(document.querySelector('#vs-screen .vs-skip'));
|
||||||
|
res.push({ i, inView: br.left >= 0 && br.right <= vw && br.top >= 0 && br.bottom <= vh, side: i === 0 ? br.right <= vw / 2 + 1 : br.left >= vw / 2 - 1, belowName: br.top >= nm.bottom - 1, infoBelowBar: ir.top >= limit + 2, noClip: b.scrollHeight <= b.clientHeight + 1 && b.scrollWidth <= b.clientWidth + 1, clearOfMark: br.right <= mark.left + 6 || br.left >= mark.right - 6 || br.bottom <= mark.top || br.top >= mark.bottom, lines: Math.round(br.height), top: Math.round(br.top), infoTop: Math.round(ir.top), limit: Math.round(limit) });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
if (r.length !== 2 || r.some(x => !x.inView || !x.side || !x.belowName || !x.infoBelowBar || !x.noClip || !x.clearOfMark)) bad.push({ p, e, r });
|
||||||
|
}
|
||||||
|
ok(`muat ${w}x${h}: balon 42 kombinasi di dalam layar, di bawah nama, tidak menimpa bilah atas/VS dan tidak terpotong`, bad.length === 0, bad.slice(0, 2));
|
||||||
|
}
|
||||||
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await page.setViewportSize({ width: 1280, height: 520 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- alur dialog rahasia ----------
|
||||||
|
if (want('secret')) {
|
||||||
|
await resetMeta();
|
||||||
|
const c0 = await game(() => window.Meta.coins);
|
||||||
|
// pasangan biasa: tanpa label, tanpa koin
|
||||||
|
await startVS('browo', 'megachan'); await wait(150); let s = await state();
|
||||||
|
ok('biasa: tanpa label DIALOG RAHASIA, tanpa balon emas, tanpa koin/egg', s.label.length === 0 && s.secretBubbles === 0 && s.bubbles.length === 2 && s.eggs.length === 0 && s.coins === c0, s);
|
||||||
|
await vsEnd(); await wait(250);
|
||||||
|
// pasangan rahasia pertama (urutan terbalik dari kunci alfabet)
|
||||||
|
await startVS('sonofsolo', 'soloman'); await wait(150); s = await state();
|
||||||
|
const D1 = await game(() => VS.dialog('sonofsolo', 'soloman'));
|
||||||
|
ok('rahasia: label DIALOG RAHASIA tampil dan kedua balon emas memakai dialog rahasia', s.label.join() === 'DIALOG RAHASIA' && s.secretBubbles === 2 && D1.secret && s.bubbles[0] === D1.l && s.bubbles[1] === D1.r, { s, D1 });
|
||||||
|
ok('rahasia: koin +75 langsung dan egg tersimpan di Meta.save.eggs', s.coins === c0 + 75 && s.eggs.join() === 'dialog:soloman|sonofsolo', { c0, s });
|
||||||
|
ok('rahasia: toast ditahan selama layar VS (tidak menutupi bilah atas)', s.active && s.toasts.length === 0, s.toasts);
|
||||||
|
await vsEnd(); await wait(400); let t = await toastsNow();
|
||||||
|
ok('rahasia: tepat satu toast DIALOG RAHASIA TERBUKA +75 setelah VS selesai', t.filter(x => /DIALOG RAHASIA TERBUKA\s+\+75 koin/.test(x)).length === 1 && t.length === 1, t);
|
||||||
|
// pertemuan kedua pasangan sama (urutan lain): label dan dialog tetap tampil, tetapi tanpa koin dan toast baru
|
||||||
|
await game(() => document.querySelectorAll('.meta-toast').forEach(n => n.remove()));
|
||||||
|
const c1 = await game(() => window.Meta.coins);
|
||||||
|
await startVS('soloman', 'sonofsolo'); await wait(150); s = await state();
|
||||||
|
ok('rahasia ulang: label dan dialog rahasia tetap tampil, koin tidak bertambah lagi', s.label.length === 1 && s.secretBubbles === 2 && s.coins === c1 && s.eggs.length === 1, s);
|
||||||
|
await vsEnd(); await wait(400); t = await toastsNow();
|
||||||
|
ok('rahasia ulang: tidak ada toast baru', t.length === 0, t);
|
||||||
|
|
||||||
|
// tiga pasangan lain: koin +75 sekali per pasangan; pencapaian REUNI baru pada pasangan keempat
|
||||||
|
const rest = [['megachan', 'anice'], ['gufron', 'ethanolman'], ['soloman', 'browo']];
|
||||||
|
for (const [i, [p, e]] of rest.entries()) {
|
||||||
|
const before = await game(() => ({ coins: window.Meta.coins, ach: !!window.Meta.save.ach.reuni }));
|
||||||
|
await startVS(p, e); await wait(150); s = await state();
|
||||||
|
const last = i === rest.length - 1;
|
||||||
|
ok(`rahasia ${p}-${e}: label, balon emas, +75 koin${last ? ' (+50 pencapaian REUNI)' : ' tanpa pencapaian'}`, s.label.length === 1 && s.secretBubbles === 2 && s.coins === before.coins + 75 + (last ? 50 : 0) && s.ach === last && !before.ach, { before, s });
|
||||||
|
await vsEnd(); await wait(400); t = await toastsNow();
|
||||||
|
ok(`rahasia ${p}-${e}: toast sesudah VS ${last ? 'berisi DIALOG RAHASIA TERBUKA dan PENCAPAIAN: REUNI' : 'hanya DIALOG RAHASIA TERBUKA'}`, t.some(x => /DIALOG RAHASIA TERBUKA/.test(x)) && t.some(x => /REUNI/.test(x)) === last && t.length === (last ? 2 : 1), t);
|
||||||
|
await game(() => document.querySelectorAll('.meta-toast').forEach(n => n.remove()));
|
||||||
|
}
|
||||||
|
const fin = await state();
|
||||||
|
ok('rahasia: keempat egg tersimpan, REUNI terbuka, koin total 4x75 + 50', fin.eggs.length === 4 && fin.ach && fin.coins === c0 + 4 * 75 + 50, fin);
|
||||||
|
// Pencapaian muncul di buku koleksi dengan nama dan deskripsi umum (tanpa menyebut pasangan)
|
||||||
|
const book = await game(() => { const M = window.Meta; M.open('book'); const row = [...document.querySelectorAll('#meta-overlay .ach li')].find(l => /REUNI/.test(l.textContent)); const txt = row?.textContent || null, ok = row?.classList.contains('ok') || false; M.close(); return { txt, ok }; });
|
||||||
|
ok('pencapaian: REUNI terbuka tampil dengan deskripsi umum', book.ok && /^★ REUNITemukan semua dialog rahasia\.$/.test(book.txt || ''), book);
|
||||||
|
// simpanan: egg bertahan di localStorage
|
||||||
|
const stored = await game(() => { try { return Object.keys(JSON.parse(localStorage.getItem('konoha.save.v1')).eggs).filter(k => k.startsWith('dialog:')).length; } catch (_) { return -1; } });
|
||||||
|
ok('simpanan: empat egg pasangan tersimpan di localStorage', stored === 4, stored);
|
||||||
|
// Toast ditahan sepanjang layar VS (dipantau tiap 40 ms, bukan hanya di awal) dan baru muncul setelah layar tidak aktif lagi
|
||||||
|
await resetMeta();
|
||||||
|
const trace = await game(() => new Promise(res => {
|
||||||
|
const g = window.__game, out = [], t0 = performance.now(); g.manual = false;
|
||||||
|
g.startMatch({ player: 'megachan', enemy: 'anice', stage: 'bellora', level: 'medium', mode: 'versus', vs: true });
|
||||||
|
const id = setInterval(() => { const t = performance.now() - t0; out.push({ t: Math.round(t), active: window.VS.active, toasts: document.querySelectorAll('.meta-toast').length }); if (t > 4200) { clearInterval(id); res(out); } }, 40);
|
||||||
|
}));
|
||||||
|
const lastActive = Math.max(...trace.filter(r => r.active).map(r => r.t)), firstToast = Math.min(...trace.filter(r => r.toasts).map(r => r.t));
|
||||||
|
ok('rahasia: toast tidak pernah tampil selagi layar VS aktif; muncul sesudah layar selesai', trace.every(r => !(r.active && r.toasts)) && firstToast > lastActive && lastActive > 2800, { lastActive, firstToast });
|
||||||
|
// karakter yang sama: tidak ada dialog dan tidak memicu apa pun
|
||||||
|
await resetMeta(); await startVS('browo', 'browo'); await wait(150); s = await state();
|
||||||
|
ok('karakter sama: kutipan umum bertanda kutip, tanpa balon, tanpa label, tanpa egg', s.bubbles.length === 0 && s.quotes.length === 2 && s.quotes.every(q => /^“.*”$/.test(q)) && s.label.length === 0 && s.eggs.length === 0, s);
|
||||||
|
await vsEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- alur: P2, Arcade, Survival, latihan, lewati, ronde ----------
|
||||||
|
if (want('flow')) {
|
||||||
|
await resetMeta();
|
||||||
|
// dua pemain (opsi vs.p2) memakai dialog pasangan juga
|
||||||
|
await startVS('anice', 'gufron', { p2: true }); await wait(150); let s = await state();
|
||||||
|
const d2 = await game(() => VS.dialog('anice', 'gufron'));
|
||||||
|
const who = await game(() => [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent).join());
|
||||||
|
ok('dua pemain: label PEMAIN 1/2 dan balon dialog pasangan', who === 'PEMAIN 1,PEMAIN 2' && s.bubbles[0] === d2.l && s.bubbles[1] === d2.r, { who, s });
|
||||||
|
await vsEnd(); await wait(300);
|
||||||
|
// pasangan rahasia di dua pemain juga memicu (egg per pasangan, bukan per sisi)
|
||||||
|
await startVS('gufron', 'ethanolman', { p2: true }); await wait(150); s = await state();
|
||||||
|
ok('dua pemain: pasangan rahasia memicu label dan egg', s.label.length === 1 && s.eggs.join() === 'dialog:ethanolman|gufron', s);
|
||||||
|
await vsEnd(); await wait(300);
|
||||||
|
// latihan: tanpa layar VS, tanpa dialog, tanpa egg
|
||||||
|
await resetMeta(); await startVS('soloman', 'sonofsolo', true, 'training'); await wait(150); s = await state();
|
||||||
|
ok('latihan: tanpa layar VS, tanpa dialog rahasia, tanpa egg', !s.active && s.bubbles.length === 0 && s.eggs.length === 0, s);
|
||||||
|
// tanpa opsi vs: perilaku lama
|
||||||
|
await startVS('soloman', 'sonofsolo', false); await wait(150); s = await state();
|
||||||
|
ok('tanpa vs: cue Round 1 langsung, tanpa dialog, tanpa egg', !s.active && s.bubbles.length === 0 && s.cues.join() === 'round1' && s.eggs.length === 0, s);
|
||||||
|
// Arcade: lawan pertama memakai dialog pasangan (pemain kiri, lawan kanan) dan label bar tetap
|
||||||
|
await game(() => { window.Meta.startRun('arcade', 'browo'); }); await wait(200); s = await state();
|
||||||
|
const arc = await game(() => { const n = [...document.querySelectorAll('#vs-screen .vs-name')].map(x => x.textContent); const ids = Object.keys(window.FIGHTERS).concat(['browo']); const en = ids.find(id => (window.FIGHTERS[id]?.name || 'BRO WO') === n[1]); return { names: n, en, d: en && VS.dialog('browo', en), top: document.querySelector('#vs-screen .vs-top')?.textContent }; });
|
||||||
|
ok('arcade: lawan 1 memakai dialog pasangan dengan pemain di kiri dan label ARCADE', s.active && arc.d && s.bubbles[0] === arc.d.l && s.bubbles[1] === arc.d.r && /ARCADE · LAWAN 1 \/ 6/.test(arc.top), { s, arc });
|
||||||
|
await game(() => { window.Meta.cancelRun(); window.__game.setMenuOpen(true); });
|
||||||
|
// Survival
|
||||||
|
await game(() => { window.Meta.startRun('survival', 'megachan'); }); await wait(200); s = await state();
|
||||||
|
const sv = await game(() => ({ top: document.querySelector('#vs-screen .vs-top')?.textContent, n: document.querySelectorAll('#vs-screen .vs-bubble').length }));
|
||||||
|
ok('survival: layar VS memakai balon dialog', s.active && sv.n === 2 && /SURVIVAL/.test(sv.top), sv);
|
||||||
|
await game(() => { window.Meta.cancelRun(); window.__game.setMenuOpen(true); });
|
||||||
|
// Dilewati: ronde jalan seperti biasa, tidak ada cue ganda
|
||||||
|
await resetMeta(); await startVS('browo', 'anice'); await wait(500); await page.keyboard.press(' '); await wait(60); s = await state();
|
||||||
|
ok('lewati: layar selesai, cue Round 1 sekali, simulasi berjalan', !s.active && s.cues.join() === 'round1', s);
|
||||||
|
await page.keyboard.press(' '); await page.keyboard.press('Enter'); await wait(60); s = await state();
|
||||||
|
ok('lewati: menekan lagi tidak menggandakan cue', s.cues.join() === 'round1', s);
|
||||||
|
// pertandingan baru saat VS rahasia masih jalan: satu overlay, egg lama tidak dobel
|
||||||
|
await resetMeta(); await startVS('soloman', 'sonofsolo'); await wait(200); await startVS('browo', 'anice'); await wait(100);
|
||||||
|
s = await state(); const cnt = await game(() => document.querySelectorAll('#vs-screen').length);
|
||||||
|
ok('batal: pertandingan baru menggantikan layar VS rahasia, label hilang, egg tetap satu', cnt === 1 && s.label.length === 0 && s.eggs.length === 1 && s.bubbles.length === 2, { s, cnt });
|
||||||
|
await vsEnd(); await wait(300);
|
||||||
|
await game(() => { window.__game.setMenuOpen(true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- waktu tampil ----------
|
||||||
|
if (want('time')) {
|
||||||
|
await resetMeta();
|
||||||
|
const dur = await game(() => ({ base: VS.duration, dlg: VS.durationDialog }));
|
||||||
|
ok('waktu: layar dengan dialog lebih lama dari layar tanpa dialog', dur.dlg > dur.base && dur.dlg <= 3600, dur);
|
||||||
|
for (const [p, e, lo, hi, label] of [['gufron', 'anice', 2800, 3800, 'dengan dialog'], ['megachan', 'megachan', 1800, 2600, 'tanpa dialog (karakter sama)']]) {
|
||||||
|
const t0 = Date.now(); await startVS(p, e); let first = null;
|
||||||
|
while (Date.now() - t0 < 5000) { const a = await game(() => window.__game.vsActive()); if (!a) { first = Date.now() - t0; break; } await wait(40); }
|
||||||
|
ok(`waktu: selesai sendiri dalam ${lo / 1000}-${hi / 1000} detik ${label}`, first !== null && first > lo && first < hi, { first });
|
||||||
|
await wait(350);
|
||||||
|
}
|
||||||
|
const s = await state();
|
||||||
|
ok('waktu: setelah selesai sendiri cue Round 1 keluar tepat sekali', s.cues.join() === 'round1', s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- screenshot untuk dinilai mata ----------
|
||||||
|
if (want('shots')) {
|
||||||
|
await resetMeta();
|
||||||
|
const shot = async (name, p, e) => { await startVS(p, e); await wait(2300); await page.screenshot({ path: path.join(dir, name + '.png') }); await game(() => window.__game.vsSkip()); await wait(450); };
|
||||||
|
for (const [w, h] of [[1280, 720], [1024, 520], [844, 390]]) {
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await shot(`biasa-${w}x${h}`, 'anice', 'gufron');
|
||||||
|
await shot(`rahasia-${w}x${h}`, 'megachan', 'anice');
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1280, height: 520 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = checks.filter(c => !c.pass);
|
||||||
|
return { total: checks.length, failed: failed.length, shots: dir, failures: failed, passed: failed.length ? undefined : checks.map(c => c.name) };
|
||||||
|
};
|
||||||
20
vs.css
20
vs.css
|
|
@ -54,6 +54,26 @@
|
||||||
}
|
}
|
||||||
@media(prefers-reduced-motion:reduce){#vs-screen,#vs-screen *{animation:none!important}#vs-screen:after{display:none}.vs-timer i{transform:none}}
|
@media(prefers-reduced-motion:reduce){#vs-screen,#vs-screen *{animation:none!important}#vs-screen:after{display:none}.vs-timer i{transform:none}}
|
||||||
|
|
||||||
|
/* Dialog pasangan (RENCANA 3.1, vs.js dialog()): balon ucapan menggantikan kutipan umum di kolom nama tiap petarung (ekor menunjuk ke papan nama);
|
||||||
|
balon kanan muncul belakangan seperti menjawab. Dialog rahasia (3.4) memberi label DIALOG RAHASIA dan balon keemasan. */
|
||||||
|
.vs-quote.vs-bubble{position:relative;margin:clamp(11px,2.4vh,20px) 0 0;padding:clamp(6px,1.2vh,10px) 12px;border:0;border-radius:14px;background:#fff3d4;color:#22180a;text-align:left;text-shadow:none;font:700 clamp(14px,1.9vh + 3px,19px)/1.2 Rajdhani,sans-serif;font-style:normal;box-shadow:0 7px 18px #000a,inset 0 -3px 0 var(--c);transform-origin:22px 0;animation:vs-bubble .42s .8s cubic-bezier(.2,1.4,.4,1) both}
|
||||||
|
.vs-side.r .vs-quote.vs-bubble{padding:clamp(6px,1.2vh,10px) 12px;border:0;transform-origin:calc(100% - 22px) 0;animation-delay:1.35s}
|
||||||
|
.vs-bubble:before{content:'';position:absolute;top:-12px;left:16px;width:22px;height:14px;background:#fff3d4;clip-path:polygon(0 100%,100% 100%,0 0)}
|
||||||
|
.vs-side.r .vs-bubble:before{left:auto;right:16px;clip-path:polygon(0 100%,100% 100%,100% 0)}
|
||||||
|
.vs-bubble.secret{background:linear-gradient(180deg,#fffbe9,#ffe6a2);box-shadow:0 7px 18px #000a,0 0 0 2px #e0bf85,0 0 18px #ffcf6e99}
|
||||||
|
.vs-bubble.secret:before{background:#fffbe9}
|
||||||
|
.vs-secret{position:absolute;z-index:6;left:50%;top:calc(clamp(8px,2.4vh,22px) + clamp(22px,3.6vh + 2px,32px));transform:translateX(-50%);display:flex;align-items:center;gap:9px;padding:2px 14px;white-space:nowrap;font-weight:700;font-size:clamp(10px,1.2vh + 3px,13px);letter-spacing:3px;color:#241a08;background:linear-gradient(90deg,#d9b46a,#fff0c4 50%,#d9b46a);box-shadow:0 0 16px #ffcf6eaa;animation:vs-secret .5s .55s cubic-bezier(.2,1.4,.4,1) both,vs-glint 1.1s 1.1s ease-in-out infinite alternate}
|
||||||
|
.vs-secret i{width:5px;height:5px;background:#241a08;transform:rotate(45deg)}
|
||||||
|
@keyframes vs-bubble{from{opacity:0;transform:translateY(8px) scale(.82)}to{opacity:1;transform:none}}
|
||||||
|
@keyframes vs-secret{from{opacity:0;transform:translateX(-50%) scale(.6)}to{opacity:1;transform:translateX(-50%)}}
|
||||||
|
@keyframes vs-glint{from{box-shadow:0 0 10px #ffcf6e88}to{box-shadow:0 0 24px #ffe29af0}}
|
||||||
|
@media(max-width:760px),(max-height:420px){
|
||||||
|
.vs-quote.vs-bubble{margin-top:9px;padding:4px 8px;border-radius:10px;font-size:clamp(11px,1.7vw,14px);line-height:1.15}
|
||||||
|
.vs-side.r .vs-quote.vs-bubble{padding:4px 8px}
|
||||||
|
.vs-bubble:before{top:-8px;left:11px;width:15px;height:10px}.vs-side.r .vs-bubble:before{left:auto;right:11px}
|
||||||
|
.vs-secret{font-size:9px;letter-spacing:2px;padding:1px 10px}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Layar hasil (menu.js screen 'result' memakai ResultView) ---- */
|
/* ---- Layar hasil (menu.js screen 'result' memakai ResultView) ---- */
|
||||||
#front-menu[data-screen=result]{display:flex;flex-direction:column;overflow:hidden}
|
#front-menu[data-screen=result]{display:flex;flex-direction:column;overflow:hidden}
|
||||||
.rs{--cw:#e0bf85;--cl:#7a8f95;position:relative;flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,.9fr) minmax(320px,1.75fr) minmax(0,.62fr);column-gap:clamp(8px,2vw,32px);align-items:stretch;width:100%;max-width:1700px;margin:0 auto;padding:0 3vw}
|
.rs{--cw:#e0bf85;--cl:#7a8f95;position:relative;flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,.9fr) minmax(320px,1.75fr) minmax(0,.62fr);column-gap:clamp(8px,2vw,32px);align-items:stretch;width:100%;max-width:1700px;margin:0 auto;padding:0 3vw}
|
||||||
|
|
|
||||||
35
vs.js
35
vs.js
|
|
@ -2,6 +2,9 @@
|
||||||
- window.VS.show(info, onDone): overlay sekitar 2,2 detik sebelum ronde 1. onDone dipanggil tepat satu kali (waktu habis atau dilewati
|
- window.VS.show(info, onDone): overlay sekitar 2,2 detik sebelum ronde 1. onDone dipanggil tepat satu kali (waktu habis atau dilewati
|
||||||
dengan klik/Spasi/Enter/Esc); setelah itu game memulai urutan cue ronde seperti biasa (game.js startMatch -> emitRoundCue).
|
dengan klik/Spasi/Enter/Esc); setelah itu game memulai urutan cue ronde seperti biasa (game.js startMatch -> emitRoundCue).
|
||||||
- window.ResultView.view(hasil, ctx) -> {title, eyebrow, html}; ResultView.mount(root) menghidupkan hitungan koin dan memberi fokus ke wadah layar.
|
- window.ResultView.view(hasil, ctx) -> {title, eyebrow, html}; ResultView.mount(root) menghidupkan hitungan koin dan memberi fokus ke wadah layar.
|
||||||
|
- Dialog pasangan (RENCANA 3.1, content/dialogs.js): bila pasangan punya dialog, kutipan pembuka diganti dua balon ucapan (kiri = pemain, kanan = lawan)
|
||||||
|
dan layar tampil lebih lama (DURATION + DIALOG_EXTRA) supaya terbaca. Pasangan tersembunyi (DIALOGS.rahasia, RENCANA 3.4) menampilkan dialog rahasia
|
||||||
|
berlabel DIALOG RAHASIA dan memanggil Meta.pairEgg (koin dan toast sekali per pasangan, pencapaian REUNI).
|
||||||
Pengacak di sini kosmetik saja (bukan random() mesin), jadi tidak memengaruhi hasil pertandingan. */
|
Pengacak di sini kosmetik saja (bukan random() mesin), jadi tidak memengaruhi hasil pertandingan. */
|
||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
@ -9,7 +12,7 @@
|
||||||
let seed = (Date.now() ^ Math.floor(performance.now() * 1000)) >>> 0;
|
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 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 LEVELS = { easy: 'MUDAH', medium: 'SEDANG', hard: 'SULIT', excellent: 'SANGAT SULIT' };
|
const LEVELS = { easy: 'MUDAH', medium: 'SEDANG', hard: 'SULIT', excellent: 'SANGAT SULIT' };
|
||||||
const DURATION = 2200, FADE = 260, MIN_SKIP = 380;
|
const DURATION = 2200, DIALOG_EXTRA = 1100, FADE = 260, MIN_SKIP = 380;
|
||||||
|
|
||||||
// ---- kutipan ----
|
// ---- kutipan ----
|
||||||
const GENERIC = { intro: ['Siap bertarung!', 'Ayo mulai.', 'Tidak ada yang mundur hari ini.'], win: ['Pertandingan yang bagus!', 'Terima kasih sudah bertanding.'], lose: ['Kali ini belum beruntung.', 'Aku akan kembali.'] };
|
const GENERIC = { intro: ['Siap bertarung!', 'Ayo mulai.', 'Tidak ada yang mundur hari ini.'], win: ['Pertandingan yang bagus!', 'Terima kasih sudah bertanding.'], lose: ['Kali ini belum beruntung.', 'Aku akan kembali.'] };
|
||||||
|
|
@ -20,6 +23,16 @@
|
||||||
if (pool.length > 1 && pool[i] === lastQuote[key]) i = (i + 1 + Math.floor(rnd() * (pool.length - 1))) % pool.length;
|
if (pool.length > 1 && pool[i] === lastQuote[key]) i = (i + 1 + Math.floor(rnd() * (pool.length - 1))) % pool.length;
|
||||||
return (lastQuote[key] = pool[i]);
|
return (lastQuote[key] = pool[i]);
|
||||||
}
|
}
|
||||||
|
// Dialog pasangan: kunci berurutan alfabet, a = ucapan karakter pertama. Hasil: {key, secret, l, r} (l = sisi kiri/pemain, r = sisi kanan/lawan) atau null
|
||||||
|
// bila pasangan tidak punya dialog (mis. karakter yang sama) sehingga kutipan umum jadi cadangan. Dialog rahasia menggantikan dialog biasa.
|
||||||
|
const pairKey = (x, y) => (String(x) < String(y) ? x + '|' + y : y + '|' + x);
|
||||||
|
function dialog(idL, idR) {
|
||||||
|
if (!idL || !idR || idL === idR) return null;
|
||||||
|
const key = pairKey(idL, idR), all = window.DIALOGS, secret = all?.rahasia?.[key], d = secret || all?.[key];
|
||||||
|
if (!d || typeof d.a !== 'string' || typeof d.b !== 'string') return null;
|
||||||
|
const first = idL < idR;
|
||||||
|
return { key, secret: !!secret, l: first ? d.a : d.b, r: first ? d.b : d.a };
|
||||||
|
}
|
||||||
function fighter(id) {
|
function fighter(id) {
|
||||||
const f = window.FIGHTERS?.[id], portrait = `assets/${id}/ui/portrait.webp`;
|
const f = window.FIGHTERS?.[id], portrait = `assets/${id}/ui/portrait.webp`;
|
||||||
if (f) return { id, name: f.name, tag: f.tag || '', art: f.select || `assets/menu/${id}-select.webp`, portrait, color: f.color || '#e0bf85' };
|
if (f) return { id, name: f.name, tag: f.tag || '', art: f.select || `assets/menu/${id}-select.webp`, portrait, color: f.color || '#e0bf85' };
|
||||||
|
|
@ -31,25 +44,31 @@
|
||||||
|
|
||||||
// ---- layar VS ----
|
// ---- layar VS ----
|
||||||
let cur = null;
|
let cur = null;
|
||||||
function vsSide(cls, f, who, q) {
|
// text = kutipan umum (diberi tanda kutip) atau, bila dlg ada, ucapan dialog pasangan dalam balon (ekor menunjuk ke papan nama).
|
||||||
return `<figure class="vs-side ${cls}" style="--c:${esc(f.color)}"><img class="vs-art" src="${esc(artOf(f))}" alt="" draggable="false"><figcaption class="vs-info"><small class="vs-who">${who}</small><strong class="vs-name">${esc(f.name)}</strong><em class="vs-tag">${esc(f.tag)}</em><p class="vs-quote">“${esc(q)}”</p></figcaption></figure>`;
|
function vsSide(cls, f, who, text, dlg) {
|
||||||
|
const talk = dlg ? `<p class="vs-quote vs-bubble${dlg.secret ? ' secret' : ''}">${esc(text)}</p>` : `<p class="vs-quote">“${esc(text)}”</p>`;
|
||||||
|
return `<figure class="vs-side ${cls}" style="--c:${esc(f.color)}"><img class="vs-art" src="${esc(artOf(f))}" alt="" draggable="false"><figcaption class="vs-info"><small class="vs-who">${who}</small><strong class="vs-name">${esc(f.name)}</strong><em class="vs-tag">${esc(f.tag)}</em>${talk}</figcaption></figure>`;
|
||||||
}
|
}
|
||||||
function show(info = {}, onDone) {
|
function show(info = {}, onDone) {
|
||||||
cancel();
|
cancel();
|
||||||
if (!document.body) return false;
|
if (!document.body) return false;
|
||||||
const L = fighter(info.player), R = fighter(info.enemy), two = !!info.p2, st = stageName(info.stage), lv = LEVELS[info.level] || '';
|
const L = fighter(info.player), R = fighter(info.enemy), two = !!info.p2, st = stageName(info.stage), lv = LEVELS[info.level] || '';
|
||||||
|
const dlg = dialog(L.id, R.id), dur = DURATION + (dlg ? DIALOG_EXTRA : 0);
|
||||||
const kicker = info.label || (two ? 'DUA PEMAIN' : 'VERSUS KOMPUTER');
|
const kicker = info.label || (two ? 'DUA PEMAIN' : 'VERSUS KOMPUTER');
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.id = 'vs-screen'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', `${L.name} melawan ${R.name}. Klik atau tekan Spasi untuk melewati.`);
|
el.id = 'vs-screen'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', `${L.name} melawan ${R.name}. Klik atau tekan Spasi untuk melewati.`);
|
||||||
const bg = window.MatchRules?.stages?.[info.stage]?.image;
|
const bg = window.MatchRules?.stages?.[info.stage]?.image;
|
||||||
el.innerHTML = `<div class="vs-bg"${bg ? ` style="background-image:url('${esc(bg)}')"` : ''}></div><i class="vs-panel l"></i><i class="vs-panel r"></i><i class="vs-slash"></i>`
|
el.innerHTML = `<div class="vs-bg"${bg ? ` style="background-image:url('${esc(bg)}')"` : ''}></div><i class="vs-panel l"></i><i class="vs-panel r"></i><i class="vs-slash"></i>`
|
||||||
+ `<div class="vs-top"><span>${esc(kicker)}</span>${st ? `<i></i><span>${esc(st.toUpperCase())}</span>` : ''}${lv && !two ? `<i></i><span>${lv}</span>` : ''}</div>`
|
+ `<div class="vs-top"><span>${esc(kicker)}</span>${st ? `<i></i><span>${esc(st.toUpperCase())}</span>` : ''}${lv && !two ? `<i></i><span>${lv}</span>` : ''}</div>`
|
||||||
+ vsSide('l', L, 'PEMAIN 1', quote(L.id, 'intro')) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', quote(R.id, 'intro'))
|
+ (dlg?.secret ? `<div class="vs-secret"><i></i><span>DIALOG RAHASIA</span><i></i></div>` : '')
|
||||||
|
+ vsSide('l', L, 'PEMAIN 1', dlg ? dlg.l : quote(L.id, 'intro'), dlg) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', dlg ? dlg.r : quote(R.id, 'intro'), dlg)
|
||||||
+ `<div class="vs-mark" aria-hidden="true"><b>VS</b></div><div class="vs-skip">KLIK ATAU SPASI UNTUK LEWATI</div><div class="vs-timer" aria-hidden="true"><i></i></div>`;
|
+ `<div class="vs-mark" aria-hidden="true"><b>VS</b></div><div class="vs-skip">KLIK ATAU SPASI UNTUK LEWATI</div><div class="vs-timer" aria-hidden="true"><i></i></div>`;
|
||||||
document.body.append(el);
|
document.body.append(el);
|
||||||
const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [] };
|
const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [], dialog: dlg };
|
||||||
el.style.setProperty('--vs-run', (DURATION - FADE) + 'ms');
|
el.style.setProperty('--vs-run', (dur - FADE) + 'ms');
|
||||||
c.timers.push(setTimeout(() => finish('time'), DURATION - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));
|
c.timers.push(setTimeout(() => finish('time'), dur - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));
|
||||||
|
// Dialog rahasia ditemukan: koin dan toast sekali per pasangan (toast ditahan Meta sampai layar VS selesai).
|
||||||
|
if (dlg?.secret) { try { window.Meta?.pairEgg?.(dlg.key); } catch (e) { console.error(e); } }
|
||||||
el.addEventListener('pointerdown', e => { e.preventDefault(); skip(); });
|
el.addEventListener('pointerdown', e => { e.preventDefault(); skip(); });
|
||||||
window.addEventListener('keydown', onKey, true);
|
window.addEventListener('keydown', onKey, true);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -73,7 +92,7 @@
|
||||||
// Dipanggil saat pertandingan dibatalkan (kembali ke menu / pertandingan baru): tanpa onDone.
|
// Dipanggil saat pertandingan dibatalkan (kembali ke menu / pertandingan baru): tanpa onDone.
|
||||||
function cancel() { const c = release(); if (!c) return; c.done = true; c.el.remove(); cur = null; }
|
function cancel() { const c = release(); if (!c) return; c.done = true; c.el.remove(); cur = null; }
|
||||||
|
|
||||||
window.VS = { show, skip, cancel, quote, fighter, duration: DURATION, get active() { return !!cur && !cur.done; }, seed(n) { seed = n >>> 0; }, force() { if (cur) { cur.t0 = -1e9; return skip(); } return false; } };
|
window.VS = { show, skip, cancel, quote, dialog, pairKey, fighter, duration: DURATION, durationDialog: DURATION + DIALOG_EXTRA, get active() { return !!cur && !cur.done; }, seed(n) { seed = n >>> 0; }, force() { if (cur) { cur.t0 = -1e9; return skip(); } return false; } };
|
||||||
|
|
||||||
// ---- layar hasil ----
|
// ---- layar hasil ----
|
||||||
const CONFETTI = ['#ffe08a', '#ffffff', '#8bd4c3', '#ff9a7a'];
|
const CONFETTI = ['#ffe08a', '#ffffff', '#8bd4c3', '#ff9a7a'];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue