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:
rizkyaz0 2026-10-04 01:17:16 +07:00
parent 4ce380873d
commit f24f7bccd9
8 changed files with 348 additions and 21 deletions

41
content/dialogs.js Normal file
View File

@ -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!' }
}
});

View File

@ -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`.
- 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.
- 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']`.
- `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.
@ -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.
## 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
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).

View File

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

@ -58,7 +58,8 @@
arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'],
survival5: ['BERTAHAN', 'Menang 5 kali berturut-turut di Survival.'],
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: {} });
let save = blank();
@ -219,7 +220,7 @@
}
function renderBook() {
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'logo'].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>`;
}
function onClick(e) {
@ -256,6 +257,15 @@
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);
}
// 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 = {
syududu: () => egg('syududu', 'SYUDUDU!', () => swarm('🐜', 40, 'ants')),
mbg: () => egg('mbg', 'TRUK KATERING LEWAT', () => swarm('🚚', 1, 'truck')),
@ -296,5 +306,5 @@
}
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; } };
})();

View File

@ -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 shot = async name => { await page.screenshot({ path: path.join(dir, name + '.png') }); };
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).
const finish = (side, rounds = 2) => game(({ side, rounds }) => {
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' });
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);
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 });
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] });
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 });
// 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 }; });
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'); }
@ -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
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); }
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();
ok('vs: cue Round 1 keluar tepat sekali setelah layar selesai', s.cues.join() === 'round1', s);
await wait(500); s = await vsState();
@ -96,7 +99,7 @@ export default async ({ page, game, errors, characters }) => {
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() }; });
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);
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');
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);
await wait(2300);
await vsEnd(); await wait(350);
}
if (want('abort')) {
// 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);
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 });
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);
// 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);
}
@ -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 });
const tDuring = await toasts();
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();
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);

233
tests/t3-dialog.mjs Normal file
View File

@ -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
View File

@ -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}}
/* 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) ---- */
#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}

35
vs.js
View File

@ -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
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.
- 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. */
(() => {
'use strict';
@ -9,7 +12,7 @@
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 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 ----
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;
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) {
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' };
@ -31,25 +44,31 @@
// ---- layar VS ----
let cur = null;
function vsSide(cls, f, who, q) {
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">&ldquo;${esc(q)}&rdquo;</p></figcaption></figure>`;
// text = kutipan umum (diberi tanda kutip) atau, bila dlg ada, ucapan dialog pasangan dalam balon (ekor menunjuk ke papan nama).
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">&ldquo;${esc(text)}&rdquo;</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) {
cancel();
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 dlg = dialog(L.id, R.id), dur = DURATION + (dlg ? DIALOG_EXTRA : 0);
const kicker = info.label || (two ? 'DUA PEMAIN' : 'VERSUS KOMPUTER');
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.`);
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>`
+ `<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>`;
document.body.append(el);
const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [] };
el.style.setProperty('--vs-run', (DURATION - FADE) + 'ms');
c.timers.push(setTimeout(() => finish('time'), DURATION - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));
const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [], dialog: dlg };
el.style.setProperty('--vs-run', (dur - FADE) + 'ms');
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(); });
window.addEventListener('keydown', onKey, true);
return true;
@ -73,7 +92,7 @@
// 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; }
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 ----
const CONFETTI = ['#ffe08a', '#ffffff', '#8bd4c3', '#ff9a7a'];