Gabung wf/t3-dialog: dialog pasangan di layar VS dan dialog rahasia (RENCANA 3.1 dan 3.4)

This commit is contained in:
rizkyaz0 2026-10-04 07:34:32 +07:00
commit f519b7aae4
7 changed files with 117 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: 'Banyak tanda tanya, ya? Boleh saya jawab pakai 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: 'Kamu 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: 'Kamu kibas sarung, aku goyang topi. Duet maut, yuk!', b: 'Boleh. Tapi koreografinya 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, reply}` memetakan ucapan ke sisi kiri (pemain) dan kanan (lawan), atau `null` bila pasangan tidak punya dialog (karakter sama) sehingga kutipan umum `QUOTES` jadi cadangan. Pasangan ber-dialog menampilkan dua balon `.vs-quote.vs-bubble` (pembuka = ucapan `a` muncul dulu, pembalas = ucapan `b` menyusul lewat kelas `.re`, apa pun sisi pemainnya) dan layar VS berlangsung `VS.durationDialog` (3,3 dtk; `VS.duration` 2,2 dtk dipakai tanpa dialog); gaya di bagian "Dialog pasangan" `vs.css`. Dialog rahasia menggantikan dialog biasa setiap pasangan itu bertemu (balon keemasan `.secret`, label `.vs-secret` DIALOG RAHASIA) dan memanggil `Meta.pairEgg(kunci)`: +75 koin dan satu toast hanya sekali per pasangan (`Meta.save.eggs['dialog:<kunci>']`), pencapaian `reuni` (tampil ??? sampai terbuka, deskripsi umum tanpa menyebut pasangan) setelah semua kunci `DIALOGS.rahasia` ditemukan. Toast ikut tertahan selama VS (aturan `Meta.toast`). Berlaku di versus, 2 pemain, Arcade, Survival; latihan tidak punya layar VS. Pedoman isi sama dengan `quotes.js`; Belum ada skenario otomatis khusus dialog (diuji manual; cek cepat: `__game.startMatch({player:'soloman',enemy:'sonofsolo',stage:'harbor',level:'easy',mode:'versus',vs:true})`, lalu `Meta.save.eggs` dan `VS.dialog(idPemain, idLawan)`). Pengujian yang menunggu layar VS selesai harus menunggu `!__game.vsActive()` (bukan tidur 2,2 dtk) karena waktunya kini bergantung pada ada-tidaknya dialog.
- HUD (game.js): meter ultimate `#ult-player`/`#ult-enemy` (`updateUltMeters`; mengisi/SIAP/waktu mode dari `modeOf`, `bwx.rage`, bentuk FENR; DOM hanya ditulis saat tampilan berubah). Menu jeda `#pause-panel` dua panel; `buildMoveList()` membangun ulang Daftar gerakan tiap `setPaused(true)`, pilihan lipat diingat di `localStorage['konoha.moveList']`.
- `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. Skenario `tests/t2-vsresult.mjs` menunggu `!__game.vsActive()` (bukan tidur 2,2 dtk) karena layar VS ber-dialog 3,3 dtk. `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);

21
vs.css
View File

@ -54,6 +54,27 @@
}
@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 pembalas (kelas .re, sisi mana pun) 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}
.vs-quote.vs-bubble.re{animation-delay:1.45s}
.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}

36
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,17 @@
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 (pembuka), b = balasan. Hasil: {key, secret, l, r, reply} (l = sisi kiri/pemain, r = sisi kanan/lawan,
// reply = sisi yang membalas, muncul belakangan) 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, reply: first ? 'r' : 'l' };
}
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 +45,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' : ''}${dlg.reply === cls ? ' re' : ''}">${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 +93,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'];