diff --git a/content/dialogs.js b/content/dialogs.js new file mode 100644 index 0000000..dc75524 --- /dev/null +++ b/content/dialogs.js @@ -0,0 +1,41 @@ +/* Dialog pasangan di layar VS (RENCANA 3.1) dan dialog rahasia berpasangan (RENCANA 3.4). + window.DIALOGS['|'] = { 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 = { '|': { 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!' } + } +}); diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index d18bd28..e2faaf0 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -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/-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['|'] = {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:']`), 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//` 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). diff --git a/index.html b/index.html index 1aa0bcf..6f53d98 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,6 @@
✦KONOHA FIGHTING

Menyiapkan arena…
KONOHA FIGHTING / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

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

KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
-
+
diff --git a/meta.js b/meta.js index 11b73d7..ac024bf 100644 --- a/meta.js +++ b/meta.js @@ -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 `
  • ${ok ? '★' : '☆'} ${hidden ? '???' : n}${hidden ? 'Pencapaian rahasia.' : d}
  • `; }).join(''); + const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'logo', 'reuni'].includes(k); return `
  • ${ok ? '★' : '☆'} ${hidden ? '???' : n}${hidden ? 'Pencapaian rahasia.' : d}
  • `; }).join(''); return `

    Statistik

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

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

      ${list}

    Suara

    Memakai suara bawaan browser.

    Simpanan

    `; } 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:']); 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; } }; })(); diff --git a/tests/t2-vsresult.mjs b/tests/t2-vsresult.mjs index a794bad..7726088 100644 --- a/tests/t2-vsresult.mjs +++ b/tests/t2-vsresult.mjs @@ -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); diff --git a/tests/t3-dialog.mjs b/tests/t3-dialog.mjs new file mode 100644 index 0000000..cd233f4 --- /dev/null +++ b/tests/t3-dialog.mjs @@ -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) }; +}; diff --git a/vs.css b/vs.css index cc158b0..b23f2ff 100644 --- a/vs.css +++ b/vs.css @@ -54,6 +54,26 @@ } @media(prefers-reduced-motion:reduce){#vs-screen,#vs-screen *{animation:none!important}#vs-screen:after{display:none}.vs-timer i{transform:none}} +/* 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} diff --git a/vs.js b/vs.js index d869a59..4fd7c70 100644 --- a/vs.js +++ b/vs.js @@ -2,6 +2,9 @@ - window.VS.show(info, onDone): overlay sekitar 2,2 detik sebelum ronde 1. onDone dipanggil tepat satu kali (waktu habis atau dilewati 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 `
    ${who}${esc(f.name)}${esc(f.tag)}

    “${esc(q)}”

    `; + // 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 ? `

    ${esc(text)}

    ` : `

    “${esc(text)}”

    `; + return `
    ${who}${esc(f.name)}${esc(f.tag)}${talk}
    `; } 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 = `
    ` + `
    ${esc(kicker)}${st ? `${esc(st.toUpperCase())}` : ''}${lv && !two ? `${lv}` : ''}
    ` - + vsSide('l', L, 'PEMAIN 1', quote(L.id, 'intro')) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', quote(R.id, 'intro')) + + (dlg?.secret ? `
    DIALOG RAHASIA
    ` : '') + + 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) + `
    KLIK ATAU SPASI UNTUK LEWATI
    `; 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'];