Dialog pasangan: balon pembalas muncul belakangan di sisi mana pun, rapikan beberapa baris dialog, dan lepas berkas uji t3-dialog (mode ringan: diuji manual); catatan mesin diperbarui

This commit is contained in:
rizkyaz0 2026-10-04 06:58:03 +07:00
parent f24f7bccd9
commit db14b18417
5 changed files with 12 additions and 243 deletions

View File

@ -15,7 +15,7 @@ Object.assign(window.DIALOGS, {
'anice|gufron': { a: 'Saya siapkan pidato. Semutmu boleh ikut menyimak.', b: 'Syududu! Kata koloni, pidatomu kepanjangan.' }, '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|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|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.' }, '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|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|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|megachan': { a: 'Joget dulu sebelum berantem, yuk. Sebentar saja!', b: 'Banteng tidak joget. Banteng menyeruduk!' },
@ -29,12 +29,12 @@ Object.assign(window.DIALOGS, {
'gufron|soloman': { a: 'Syududu! Semutku boleh ikut konvoi Esemka?', b: 'Boleh. Naik saja di kap, tanpa tiket. Sederhana.' }, '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.' }, '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|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.' }, '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?' }, '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). // Dialog rahasia: empat pasangan tersembunyi (data terpisah dari dialog biasa).
rahasia: { rahasia: {
'soloman|sonofsolo': { a: 'Dua Solo di satu arena. Kotanya cukup, kan?', b: 'Cukup. Satu pertanyaan saja: Esemkamu isi asam sulfat?' }, '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.' }, '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!' }, '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!' } 'ethanolman|gufron': { a: 'Hahaha! Etanol dari tebu itu manis. Semutmu suka?', b: 'Syududu! Koloni sudah mengantre di pabrik gulanya!' }
} }

View File

@ -19,7 +19,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`. - `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`.
- Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache). - Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache).
- `vs.js` (`window.VS`, `window.ResultView`) + `vs.css` + `content/quotes.js` (`window.QUOTES`: intro/win/lose per karakter): layar VS dan layar hasil. `startMatch({..., vs: true | {label, p2}})` menampilkan layar VS 2,2 dtk (hanya mode versus; latihan tidak); selama itu `vsHold` di game.js menahan `advanceMatch` dan cue 'Round 1' keluar tepat sekali saat VS selesai atau dilewati (klik/Spasi/Enter/Esc setelah 0,38 dtk). `startMatch` tanpa `vs` berperilaku seperti dulu. `menu.js` screen `result` memakai `ResultView.view/mount` (layar lama jadi cadangan); `Meta.award` menyimpan rincian di `Meta.lastAward`; `Meta.nextFight`/`finishRun` memakai VS dan layar hasil yang sama. Pemilih kutipan `VS.quote(id, 'intro'|'win'|'lose')` memakai pengacak kosmetik terpisah. API uji: `__game.vsActive()`, `__game.vsSkip()`. Menu depan yang tersembunyi dijamin hilang oleh `#front-menu[hidden]{display:none!important}` (menu.css); aturan `display` per `data-screen` tidak boleh menimpanya. Layar hasil memfokuskan wadah `.rs` (`tabindex=-1`), bukan tombol, agar Spasi/Enter dari pertarungan tidak langsung memicu MAIN LAGI (Tab tetap menjangkau tombol). `Meta.toast` menahan toast selama `VS.active` dan memunculkannya sesudah VS selesai. - `vs.js` (`window.VS`, `window.ResultView`) + `vs.css` + `content/quotes.js` (`window.QUOTES`: intro/win/lose per karakter): layar VS dan layar hasil. `startMatch({..., vs: true | {label, p2}})` menampilkan layar VS 2,2 dtk (hanya mode versus; latihan tidak); selama itu `vsHold` di game.js menahan `advanceMatch` dan cue 'Round 1' keluar tepat sekali saat VS selesai atau dilewati (klik/Spasi/Enter/Esc setelah 0,38 dtk). `startMatch` tanpa `vs` berperilaku seperti dulu. `menu.js` screen `result` memakai `ResultView.view/mount` (layar lama jadi cadangan); `Meta.award` menyimpan rincian di `Meta.lastAward`; `Meta.nextFight`/`finishRun` memakai VS dan layar hasil yang sama. Pemilih kutipan `VS.quote(id, 'intro'|'win'|'lose')` memakai pengacak kosmetik terpisah. API uji: `__game.vsActive()`, `__game.vsSkip()`. Menu depan yang tersembunyi dijamin hilang oleh `#front-menu[hidden]{display:none!important}` (menu.css); aturan `display` per `data-screen` tidak boleh menimpanya. Layar hasil memfokuskan wadah `.rs` (`tabindex=-1`), bukan tombol, agar Spasi/Enter dari pertarungan tidak langsung memicu MAIN LAGI (Tab tetap menjangkau tombol). `Meta.toast` menahan toast selama `VS.active` dan memunculkannya sesudah VS selesai.
- Dialog pasangan dan easter egg berpasangan (Tahap 3.1 dan 3.4): `content/dialogs.js` (dimuat sebelum `vs.js`) mengisi `window.DIALOGS['<idA>|<idB>'] = {a, b}` untuk 21 pasangan (kunci urut alfabet; a = ucapan idA) dan `window.DIALOGS.rahasia` (empat pasangan tersembunyi, bentuk sama, bukan bagian dari 21 kunci). `VS.dialog(idPemain, idLawan)` -> `{key, secret, l, r}` memetakan ucapan ke sisi kiri (pemain) dan kanan (lawan), atau `null` bila pasangan tidak punya dialog (karakter sama) sehingga kutipan umum `QUOTES` jadi cadangan. Pasangan ber-dialog menampilkan dua balon `.vs-quote.vs-bubble` (kiri muncul dulu, kanan membalas) dan layar VS berlangsung `VS.durationDialog` (3,3 dtk; `VS.duration` 2,2 dtk dipakai tanpa dialog); gaya di bagian "Dialog pasangan" `vs.css`. Dialog rahasia menggantikan dialog biasa setiap pasangan itu bertemu (balon keemasan `.secret`, label `.vs-secret` DIALOG RAHASIA) dan memanggil `Meta.pairEgg(kunci)`: +75 koin dan satu toast hanya sekali per pasangan (`Meta.save.eggs['dialog:<kunci>']`), pencapaian `reuni` (tampil ??? sampai terbuka, deskripsi umum tanpa menyebut pasangan) setelah semua kunci `DIALOGS.rahasia` ditemukan. Toast ikut tertahan selama VS (aturan `Meta.toast`). Berlaku di versus, 2 pemain, Arcade, Survival; latihan tidak punya layar VS. Pedoman isi sama dengan `quotes.js`; `tests/t3-dialog.mjs` (env `DLG_SHOTS`, `DLG_ONLY=data|map|fit|secret|flow|time|shots`) memeriksa data, pemetaan sisi, muat di 568x320 sampai 1280x720 untuk 42 kombinasi, alur egg, dan waktu tampil. Pengujian yang menunggu layar VS selesai harus menunggu `!__game.vsActive()` (bukan tidur 2,2 dtk) karena waktunya kini bergantung pada ada-tidaknya dialog. - 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']`. - HUD (game.js): meter ultimate `#ult-player`/`#ult-enemy` (`updateUltMeters`; mengisi/SIAP/waktu mode dari `modeOf`, `bwx.rage`, bentuk FENR; DOM hanya ditulis saat tampilan berubah). Menu jeda `#pause-panel` dua panel; `buildMoveList()` membangun ulang Daftar gerakan tiap `setPaused(true)`, pilihan lipat diingat di `localStorage['konoha.moveList']`.
- `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter. - `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter.
- `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang. - `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang.
@ -30,7 +30,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
`__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot. `__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot.
## Alat uji headless ## Alat uji headless
`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-hud.mjs` (env `HUD_ONLY=meter|enemy|cpu|pause|layout`) menguji meter ultimate dan menu jeda; `tests/t2-vsresult.mjs` (env `VSR_SHOTS`, `VSR_ONLY=quotes|vs|skip|abort|result|run|ui|focus|phone`) menguji layar VS, layar hasil, dan kutipan. `tests/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. `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 ## Aturan kerja
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap). - Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).

View File

@ -1,233 +0,0 @@
// 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) };
};

5
vs.css
View File

@ -55,9 +55,10 @@
@media(prefers-reduced-motion:reduce){#vs-screen,#vs-screen *{animation:none!important}#vs-screen:after{display:none}.vs-timer i{transform:none}} @media(prefers-reduced-motion:reduce){#vs-screen,#vs-screen *{animation:none!important}#vs-screen:after{display:none}.vs-timer i{transform:none}}
/* Dialog pasangan (RENCANA 3.1, vs.js dialog()): balon ucapan menggantikan kutipan umum di kolom nama tiap petarung (ekor menunjuk ke papan nama); /* 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. */ 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-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-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-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-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{background:linear-gradient(180deg,#fffbe9,#ffe6a2);box-shadow:0 7px 18px #000a,0 0 0 2px #e0bf85,0 0 18px #ffcf6e99}

7
vs.js
View File

@ -23,7 +23,8 @@
if (pool.length > 1 && pool[i] === lastQuote[key]) i = (i + 1 + Math.floor(rnd() * (pool.length - 1))) % pool.length; if (pool.length > 1 && pool[i] === lastQuote[key]) i = (i + 1 + Math.floor(rnd() * (pool.length - 1))) % pool.length;
return (lastQuote[key] = pool[i]); return (lastQuote[key] = pool[i]);
} }
// Dialog pasangan: kunci berurutan alfabet, a = ucapan karakter pertama. Hasil: {key, secret, l, r} (l = sisi kiri/pemain, r = sisi kanan/lawan) atau null // 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. // 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); const pairKey = (x, y) => (String(x) < String(y) ? x + '|' + y : y + '|' + x);
function dialog(idL, idR) { function dialog(idL, idR) {
@ -31,7 +32,7 @@
const key = pairKey(idL, idR), all = window.DIALOGS, secret = all?.rahasia?.[key], d = secret || all?.[key]; 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; if (!d || typeof d.a !== 'string' || typeof d.b !== 'string') return null;
const first = idL < idR; const first = idL < idR;
return { key, secret: !!secret, l: first ? d.a : d.b, r: first ? d.b : d.a }; return { key, secret: !!secret, l: first ? d.a : d.b, r: first ? d.b : d.a, reply: first ? 'r' : 'l' };
} }
function fighter(id) { function fighter(id) {
const f = window.FIGHTERS?.[id], portrait = `assets/${id}/ui/portrait.webp`; const f = window.FIGHTERS?.[id], portrait = `assets/${id}/ui/portrait.webp`;
@ -46,7 +47,7 @@
let cur = null; let cur = null;
// text = kutipan umum (diberi tanda kutip) atau, bila dlg ada, ucapan dialog pasangan dalam balon (ekor menunjuk ke papan nama). // 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) { function vsSide(cls, f, who, text, dlg) {
const talk = dlg ? `<p class="vs-quote vs-bubble${dlg.secret ? ' secret' : ''}">${esc(text)}</p>` : `<p class="vs-quote">&ldquo;${esc(text)}&rdquo;</p>`; 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>`; return `<figure class="vs-side ${cls}" style="--c:${esc(f.color)}"><img class="vs-art" src="${esc(artOf(f))}" alt="" draggable="false"><figcaption class="vs-info"><small class="vs-who">${who}</small><strong class="vs-name">${esc(f.name)}</strong><em class="vs-tag">${esc(f.tag)}</em>${talk}</figcaption></figure>`;
} }
function show(info = {}, onDone) { function show(info = {}, onDone) {