Gabung wf/t7-record

This commit is contained in:
rizkyaz0 2026-10-05 14:49:04 +07:00
commit 70e256b2a2
5 changed files with 502 additions and 1 deletions

View File

@ -19,6 +19,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`. - `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`.
- Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa. - Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa.
- `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`. - `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`.
- Rekam ulang K.O. dan tangkapan layar (RENCANA 7.3 dan 7.4): `record.js` (`window.Record`) + `record.css`; tag di baris sendiri sebelum `</main>` di `index.html`, **game.js tidak diubah**. Kait lain hanya di `vs.js`: `ResultView.view` menambahkan `window.Record?.resultHtml(r)` di dalam `.rs-main` (toolbar `.rs-rec` yang melayang di pojok kanan atas kolom tengah, jadi tinggi layar hasil tidak bertambah) dan `ResultView.mount` memanggil `Record.mount(root)`. Keadaan permainan dibaca dari DOM tiap 150 ms (`poll`): `body.in-menu`, `#arena[data-mode|data-match-phase|data-character|data-opponent]` (diisi `updateHud` tiap frame), `dialog[open]`, `VS.active`/`Story.active`, `document.hidden`; `__game.startMatch` dibungkus hanya untuk memulai sesi baru seketika. **Perekam**: kanvas salinan <= 854 px lebar (640 di layar sentuh) diisi dari `#arena` ~30 kali/dtk oleh rAF sendiri (letterbox bila rasio jendela berubah), `captureStream()` -> `MediaRecorder` webm (vp8; vp9/webm cadangan), tanpa audio, 1,5 Mbps (0,9 di layar sentuh), `timeslice` 1 dtk. Potongan WebM tidak bisa dipangkas di depan (hanya potongan pertama memuat header dan keyframe), jadi dipakai **dua perekam bergantian**: perekam baru dimulai tiap 6 dtk waktu rekam, tiap perekam berhenti dan dibuang sesudah 12 dtk, klip = perekam tertua (6 sampai 12 dtk, satu berkas WebM utuh tanpa menyambung kontainer). Jeda/Pengaturan/layar VS/kartu cerita/tab tersembunyi = `pause()` (jam rekam ikut berhenti); menu atau pertandingan dibatalkan = dibuang; latihan tidak direkam. Saat `matchPhase` menjadi `complete` (atau `resultHtml` lebih dulu, karena layar hasil dipasang pada tick yang sama dengan event itu) perekam tertua dihentikan jadi klip dan bingkai terakhir arena dibekukan untuk tombol FOTO di layar hasil; klip dan bingkai dibuang saat pertandingan berikutnya mulai. Layar hasil: SIMPAN KLIP (`konoha-fighting-<karakter>-vs-<lawan>.webm`), TONTON ULANG (`<dialog id=rec-replay>` modal, Esc dan tombol B gamepad menutup), FOTO, BAGIKAN (`navigator.share` dengan berkas, selain itu unduh; PNG dibuat sinkron lewat `toDataURL` agar masih dalam gestur klik). HUD: tombol kamera di `.match-actions` (disembunyikan di lebar <= 620 px) dan penanda REC di bawah barisnya; menu jeda: baris TANGKAP LAYAR dan BAGIKAN; pintasan **F9** (arena, jeda, layar hasil). Pengaturan: dua baris disisipkan setelah slider Suara (Rekam ulang K.O. = saklar utama; Indikator REC = penanda saja). Simpanan `localStorage['konoha.record.v1'] = {on, badge}`. Bila `MediaRecorder`/webm/`captureStream` tidak ada atau terjadi galat, klip dan baris Pengaturan hilang tanpa suara (tangkapan layar tetap jalan). Konsol: `Record.state()`, `Record.set(false)`. Belum ada skenario otomatis (mode ringan, diuji manual); nilai di bagian atas record.js (`PERIOD`, `FPS`, bitrate di `start`) bisa disetel.
- Gamepad dan ubah tombol (Tahap 4.5): `input.js` + `input.css` (`window.Input`), dimuat tepat setelah `touch.js`; satu-satunya kait ke game.js adalah `__game.input` (plus pembungkus `__game.startMatch/setP2` untuk mengetahui mode dua pemain; bila kelak ada `__game.p2Active()`, itu yang dipakai). **Ubah tombol** (Pengaturan > "Ubah tombol…", dialog `#keymap-dialog`): 11 aksi Pemain 1 disimpan di `localStorage['konoha.keymap.v1']` sebagai `{aksi: {c: e.code, l: label}}`. Hanya bila pemetaan berbeda dari bawaan, keydown/keyup ditangkap di fase capture pada `window`, diterjemahkan ke kunci kanonik (a d w s spasi i o p k l j) lewat `__game.input`, dan event aslinya dihentikan; tombol bawaan yang sudah dipindah ditelan (termasuk keyup-nya, supaya game.js tidak melepas kunci kanonik yang ditahan lewat tombol baru). Tidak aktif di menu depan (`body.in-menu`), dialog terbuka, layar VS/kartu cerita, atau kolom teks; Spasi/Enter pada `<button>` dibiarkan seperti di game.js. Tidak bisa dipetakan: panah, N, M, koma, titik (Pemain 2), Esc, R, Enter, Tab, Ctrl/Alt/Cmd, tombol F; bentrok antaraksi = kedua tombol ditukar. Bantuan kontrol di Pengaturan ditulis ulang dari teks aslinya (5 baris pertama), label `kbd` di HUD (kartu skill, meter ultimate; meter ditulis ulang game.js tiap keadaannya berubah jadi diamati) dan Daftar gerakan menu jeda (hanya bagian Kontrol dasar, Serangan dan skill, Gerakan perintah, Mode) mengikuti pemetaan lewat MutationObserver tanpa mengubah game.js. Daftar gerakan di menu Karakter (meta.js) dan teks "tombol K/I/O" di toko belum mengikuti. **Gamepad**: polling `requestAnimationFrame` hanya selama ada gamepad (tata letak standar; pad pertama = P1, pad kedua = P2 bila mode dua pemain). Di arena: stik/D-pad = a d w s, A/Cross = w, X/Square = spasi, Y/Triangle, B/Circle, R1 = i o p, L1/L2/R2 = k l j, Start = Esc; pad P2 memakai kunci P2 (panah, n m , .). Di menu depan: keydown sintetis ArrowKeys/Enter/Escape yang ditangani menu.js (bila tidak ditangani, fokus dipindah dan tombol diklik); di dialog (pengaturan, jeda, ubah tombol), hamparan Meta, layar VS dan kartu cerita: fokus dipindah langsung, A = klik/Enter, B/Start = tutup (Esc lewat `__game.input('escape')`). Kontrol yang sedang ditekan saat konteks berganti diabaikan sampai dilepas. Getaran lewat `vibrationActuator` saat `hero.hp` turun (dimatikan lewat Pengaturan, `localStorage['konoha.pad.v1']`). Gamepad API hanya jalan di `localhost`/HTTPS. Belum ada skenario otomatis (diuji manual). - Gamepad dan ubah tombol (Tahap 4.5): `input.js` + `input.css` (`window.Input`), dimuat tepat setelah `touch.js`; satu-satunya kait ke game.js adalah `__game.input` (plus pembungkus `__game.startMatch/setP2` untuk mengetahui mode dua pemain; bila kelak ada `__game.p2Active()`, itu yang dipakai). **Ubah tombol** (Pengaturan > "Ubah tombol…", dialog `#keymap-dialog`): 11 aksi Pemain 1 disimpan di `localStorage['konoha.keymap.v1']` sebagai `{aksi: {c: e.code, l: label}}`. Hanya bila pemetaan berbeda dari bawaan, keydown/keyup ditangkap di fase capture pada `window`, diterjemahkan ke kunci kanonik (a d w s spasi i o p k l j) lewat `__game.input`, dan event aslinya dihentikan; tombol bawaan yang sudah dipindah ditelan (termasuk keyup-nya, supaya game.js tidak melepas kunci kanonik yang ditahan lewat tombol baru). Tidak aktif di menu depan (`body.in-menu`), dialog terbuka, layar VS/kartu cerita, atau kolom teks; Spasi/Enter pada `<button>` dibiarkan seperti di game.js. Tidak bisa dipetakan: panah, N, M, koma, titik (Pemain 2), Esc, R, Enter, Tab, Ctrl/Alt/Cmd, tombol F; bentrok antaraksi = kedua tombol ditukar. Bantuan kontrol di Pengaturan ditulis ulang dari teks aslinya (5 baris pertama), label `kbd` di HUD (kartu skill, meter ultimate; meter ditulis ulang game.js tiap keadaannya berubah jadi diamati) dan Daftar gerakan menu jeda (hanya bagian Kontrol dasar, Serangan dan skill, Gerakan perintah, Mode) mengikuti pemetaan lewat MutationObserver tanpa mengubah game.js. Daftar gerakan di menu Karakter (meta.js) dan teks "tombol K/I/O" di toko belum mengikuti. **Gamepad**: polling `requestAnimationFrame` hanya selama ada gamepad (tata letak standar; pad pertama = P1, pad kedua = P2 bila mode dua pemain). Di arena: stik/D-pad = a d w s, A/Cross = w, X/Square = spasi, Y/Triangle, B/Circle, R1 = i o p, L1/L2/R2 = k l j, Start = Esc; pad P2 memakai kunci P2 (panah, n m , .). Di menu depan: keydown sintetis ArrowKeys/Enter/Escape yang ditangani menu.js (bila tidak ditangani, fokus dipindah dan tombol diklik); di dialog (pengaturan, jeda, ubah tombol), hamparan Meta, layar VS dan kartu cerita: fokus dipindah langsung, A = klik/Enter, B/Start = tutup (Esc lewat `__game.input('escape')`). Kontrol yang sedang ditekan saat konteks berganti diabaikan sampai dilepas. Getaran lewat `vibrationActuator` saat `hero.hp` turun (dimatikan lewat Pengaturan, `localStorage['konoha.pad.v1']`). Gamepad API hanya jalan di `localhost`/HTTPS. Belum ada skenario otomatis (diuji manual).
- Tata letak sentuh yang bisa diatur (RENCANA 4.6): `touchlayout.js` + `touchlayout.css`, dimuat `defer` di `<head>` index.html (satu baris tag baru bersama manifest dan `pwa.js`); hanya aktif di `html.touch`, `touch.js` tidak diubah. Pengaturan mendapat baris `#tl-row` (disisipkan lewat JS sebelum `.dialog-buttons`) yang membuka editor `#tl-editor`: `<dialog>` modal layar penuh yang menumpuk di atas Pengaturan (Pengaturan disembunyikan lewat `html.tl-editing`, tetap terbuka sehingga `settingsOpen` menahan game; Esc ditangkap fase capture agar game tidak menutup Pengaturan). Di dalamnya ada salinan (`cloneNode`, tanpa pendengar dan `inert`) `.dpad`, `.action-pad`, `.extra-pad` plus kotak seret `.tl-box` dengan pegangan sudut; panah/+/- juga bekerja di papan ketik. Hasil ke kontrol asli hanya kelas `.tl-xf` + variabel `--tl-x/--tl-y/--tl-s` (`transform: translate() scale()`, titik tumpu sudut dekat tepi); elemen bawaan tidak disentuh sama sekali. Simpanan `localStorage['konoha.touchlayout.v1']` = `{v:1, g:{dpad|action|extra:{x,y,s}}}` (x,y = pecahan lebar/tinggi layar, s = skala 0,6-1,8). Penjepitan ke layar memakai `getBoundingClientRect` nyata (area = layar - tepi aman - 4 px - bilah editor 46 px di atas), dijalankan `ResizeObserver` pada `#touch-controls` saat kontrol tampil/ukuran berubah; nilai simpanan tidak ditimpa penjepitan. `window.TouchLayout` = `{open(), editing, layout}` (konsol: `TouchLayout.open()`). Belum ada skenario otomatis dan belum pernah dijalankan di peramban oleh penulisnya (hanya `node --check` dan smoke desktop): uji manual di 844x390 dengan `?touch=1`. - Tata letak sentuh yang bisa diatur (RENCANA 4.6): `touchlayout.js` + `touchlayout.css`, dimuat `defer` di `<head>` index.html (satu baris tag baru bersama manifest dan `pwa.js`); hanya aktif di `html.touch`, `touch.js` tidak diubah. Pengaturan mendapat baris `#tl-row` (disisipkan lewat JS sebelum `.dialog-buttons`) yang membuka editor `#tl-editor`: `<dialog>` modal layar penuh yang menumpuk di atas Pengaturan (Pengaturan disembunyikan lewat `html.tl-editing`, tetap terbuka sehingga `settingsOpen` menahan game; Esc ditangkap fase capture agar game tidak menutup Pengaturan). Di dalamnya ada salinan (`cloneNode`, tanpa pendengar dan `inert`) `.dpad`, `.action-pad`, `.extra-pad` plus kotak seret `.tl-box` dengan pegangan sudut; panah/+/- juga bekerja di papan ketik. Hasil ke kontrol asli hanya kelas `.tl-xf` + variabel `--tl-x/--tl-y/--tl-s` (`transform: translate() scale()`, titik tumpu sudut dekat tepi); elemen bawaan tidak disentuh sama sekali. Simpanan `localStorage['konoha.touchlayout.v1']` = `{v:1, g:{dpad|action|extra:{x,y,s}}}` (x,y = pecahan lebar/tinggi layar, s = skala 0,6-1,8). Penjepitan ke layar memakai `getBoundingClientRect` nyata (area = layar - tepi aman - 4 px - bilah editor 46 px di atas), dijalankan `ResizeObserver` pada `#touch-controls` saat kontrol tampil/ukuran berubah; nilai simpanan tidak ditimpa penjepitan. `window.TouchLayout` = `{open(), editing, layout}` (konsol: `TouchLayout.open()`). Belum ada skenario otomatis dan belum pernah dijalankan di peramban oleh penulisnya (hanya `node --check` dan smoke desktop): uji manual di 844x390 dengan `?touch=1`.
- PWA (RENCANA 4.7): `manifest.webmanifest` (id `konoha-fighting`, `display: fullscreen` + `display_override [fullscreen, standalone]`, landscape, warna `#101923`, ikon PNG any dan maskable 192/512) ditautkan di index.html dan mobile.html bersama `rel=icon` dan `apple-touch-icon`. Ikon di `assets/pwa/` dibuat `tools/make_pwa_icons.py` (hanya Pillow) dari `assets/menu/browo-select.webp`; jalankan ulang bila ingin mengganti gambar. `pwa.js` (`window.KonohaPWA = {available(), install(), onChange(fn)}`) dimuat sinkron di `<head>` kedua halaman karena `beforeinstallprompt` datang ke dokumen paling atas: di HP itu `mobile.html`, dan iframe game memakai `window.parent.KonohaPWA` (panggilan langsung supaya gestur ketuk tetap berlaku untuk `prompt()`). Baris `#pwa-row` ("Pasang aplikasi") di Pengaturan hanya tampil bila event tersedia dan belum terpasang; iOS tidak punya event itu, jadi di sana tombol tidak tampil. `sw.js`: perilaku lama dipertahankan (gambar/font cache dulu, HTML/CSS/JS jaringan dulu); navigasi yang gagal dan tidak ada di cache mendapat `offline.html` dari cache kecil `konoha-offline-v1` (terpisah dari `aether-assets` karena `preload.js` menghapus entri yang tidak ada di `precache.js`). `tools/build_precache.py` kini memuat `manifest.webmanifest` dan `assets/pwa/*` (`offline.html`, `pwa.js`, `touchlayout.*` ikut otomatis); `precache.js`/`files.json` perlu dibangun ulang oleh integrator. mp3/mp4 tetap dilayani jaringan, jadi tanpa internet game jalan tanpa musik. Server perlu MIME `application/manifest+json` untuk `.webmanifest` (sudah di `.htaccess`). - PWA (RENCANA 4.7): `manifest.webmanifest` (id `konoha-fighting`, `display: fullscreen` + `display_override [fullscreen, standalone]`, landscape, warna `#101923`, ikon PNG any dan maskable 192/512) ditautkan di index.html dan mobile.html bersama `rel=icon` dan `apple-touch-icon`. Ikon di `assets/pwa/` dibuat `tools/make_pwa_icons.py` (hanya Pillow) dari `assets/menu/browo-select.webp`; jalankan ulang bila ingin mengganti gambar. `pwa.js` (`window.KonohaPWA = {available(), install(), onChange(fn)}`) dimuat sinkron di `<head>` kedua halaman karena `beforeinstallprompt` datang ke dokumen paling atas: di HP itu `mobile.html`, dan iframe game memakai `window.parent.KonohaPWA` (panggilan langsung supaya gestur ketuk tetap berlaku untuk `prompt()`). Baris `#pwa-row` ("Pasang aplikasi") di Pengaturan hanya tampil bila event tersedia dan belum terpasang; iOS tidak punya event itu, jadi di sana tombol tidak tampil. `sw.js`: perilaku lama dipertahankan (gambar/font cache dulu, HTML/CSS/JS jaringan dulu); navigasi yang gagal dan tidak ada di cache mendapat `offline.html` dari cache kecil `konoha-offline-v1` (terpisah dari `aether-assets` karena `preload.js` menghapus entri yang tidak ada di `precache.js`). `tools/build_precache.py` kini memuat `manifest.webmanifest` dan `assets/pwa/*` (`offline.html`, `pwa.js`, `touchlayout.*` ikut otomatis); `precache.js`/`files.json` perlu dibangun ulang oleh integrator. mp3/mp4 tetap dilayani jaringan, jadi tanpa internet game jalan tanpa musik. Server perlu MIME `application/manifest+json` untuk `.webmanifest` (sudah di `.htaccess`).

View File

@ -27,6 +27,8 @@
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog> <dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
<!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js --> <!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js -->
<link rel="stylesheet" href="stats.css"><script src="stats.js"></script> <link rel="stylesheet" href="stats.css"><script src="stats.js"></script>
<!-- Rekam ulang K.O. dan tangkapan layar (RENCANA 7.3 dan 7.4): record.js memasang tombol kamera di HUD, baris di jeda dan Pengaturan; kait kecil di vs.js (layar hasil) -->
<link rel="stylesheet" href="record.css"><script src="record.js"></script>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="tutorial.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html> </main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="tutorial.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>

53
record.css Normal file
View File

@ -0,0 +1,53 @@
/* Rekam ulang K.O. dan ekspor tangkapan layar (record.js, RENCANA 7.3 dan 7.4): tombol kamera di HUD, indikator REC, baris di menu jeda, toolbar di layar hasil, dialog Tonton ulang.
Tinggi minimum yang didukung tetap 520 px: toolbar layar hasil melayang (absolute) di pojok kanan atas kolom tengah dan tidak menambah tinggi tata letak; dialog bisa digulir. */
.rec-ic{width:1.25em;height:1.25em;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
/* ---- HUD: tombol kamera memakai gaya .match-actions button (style.css); REC menempel di bawah barisnya tanpa mengubah tata letak ---- */
#rec-shot.rec-done{border-color:#8dffa8;background:#1f5a45}
@media(max-width:620px){#rec-shot{display:none}} /* kolom tengah header terlalu sempit untuk tiga tombol; F9 dan tombol di menu jeda tetap ada */
.rec-badge{position:absolute;top:calc(100% + 4px);left:50%;transform:translateX(-50%);display:inline-flex;align-items:center;gap:5px;padding:0 7px 0 6px;font:700 11px/15px Rajdhani,Arial,sans-serif;letter-spacing:1.6px;color:#ffd9d3;background:#1a0c0fcc;border:1px solid #ff6a5a88;pointer-events:none;white-space:nowrap}
.rec-badge[hidden]{display:none}
.rec-badge i{width:7px;height:7px;border-radius:50%;background:#ff4a3d;box-shadow:0 0 6px #ff4a3d;animation:rec-blink 1.4s ease-in-out infinite}
@keyframes rec-blink{50%{opacity:.3}}
/* ---- Menu jeda ---- */
.rec-btns{display:flex;gap:8px;margin-top:12px}
.rec-btns .rec-b{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;min-width:0;border:1px solid #8da8b153;background:#274352;padding:8px;font-size:15px;font-weight:700;letter-spacing:.4px}
.rec-btns .rec-b:hover{background:#2f5365}
#pause-panel .rec-btns{margin-top:10px}
/* ---- Layar hasil: toolbar di pojok kanan atas .rs-main (di atas kepala hasil, di pita kosong tempat konten diratakan ke tengah) ---- */
.rs-rec{position:absolute;top:0;right:0;z-index:3;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;max-width:100%;animation:rs-up .5s 1s both}
.rs-rec .rec-b{display:inline-flex;align-items:center;gap:6px;margin:0;padding:clamp(4px,.9vh,7px) clamp(7px,1vw,11px);font:700 clamp(10px,1.3vh + 2px,13px)/1 Rajdhani,Arial,sans-serif;letter-spacing:1.5px;color:#cbddd4;background:#0b1c26d9;border:1px solid #8ba89d66;border-radius:0;cursor:pointer;white-space:nowrap}
.rs-rec .rec-b:hover:not([disabled]),.rs-rec .rec-b:focus-visible{background:#24505e;color:#fff}
.rs-rec .rec-b[disabled]{opacity:.5;cursor:default}
.rs-rec .rec-b[aria-busy=true] .rec-ic{animation:rec-pulse 1s ease-in-out infinite}
@keyframes rec-pulse{50%{opacity:.35}}
#front-menu .rs-rec .rec-b:focus-visible{outline-offset:-3px}
@media(max-width:700px),(max-height:420px){.rs-rec{gap:4px}.rs-rec .rec-b{padding:4px 7px;gap:5px;letter-spacing:.8px}}
@media(max-width:620px){.rs-rec .t{display:none}.rs-rec .rec-b{padding:6px 8px}} /* hanya ikon; nama tombol tetap ada lewat aria-label dan title */
@media(pointer:coarse){.rs-rec .rec-b{min-height:32px}}
/* ---- Umpan balik: toast kecil di bawah tengah dan kilat kamera ---- */
.rec-toast{position:fixed;left:50%;bottom:max(16px,env(safe-area-inset-bottom));transform:translateX(-50%);z-index:120;max-width:min(92vw,520px);padding:8px 16px;background:#101923ee;border:1px solid #e8b84a;color:#f6eedb;font:700 16px/1.25 Rajdhani,Arial,sans-serif;letter-spacing:.05em;text-align:center;pointer-events:none;animation:rec-toast .2s ease-out both}
.rec-toast.bad{border-color:#ff8a7a;color:#ffd9d3}
@keyframes rec-toast{from{opacity:0;transform:translate(-50%,8px)}to{opacity:1;transform:translateX(-50%)}}
.rec-flash{position:fixed;inset:0;z-index:119;background:#fff;pointer-events:none;animation:rec-flash .3s ease-out both}
@keyframes rec-flash{from{opacity:.75}to{opacity:0}}
/* ---- Tonton ulang ---- */
#rec-replay{width:min(94vw,860px);max-width:calc(100% - 16px);padding:10px 14px 12px;overflow:auto}
#rec-replay .dialog-kicker{font-size:11px}
#rec-replay h2{font-size:22px;margin:2px 0 8px;letter-spacing:1px}
#rec-replay video{display:block;width:100%;height:auto;max-height:calc(100vh - 160px);background:#000;object-fit:contain;cursor:pointer}
.rp-bar{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.rp-bar button{flex:1 1 88px;border:1px solid #8da8b153;background:#274352;padding:8px;font-size:15px;font-weight:700;letter-spacing:.4px}
.rp-bar button:hover{background:#2f5365}
.rp-bar button[data-rp=close]{border-color:transparent;background:var(--gold);color:#132b37}
.rp-bar button[data-rp=close]:hover{background:#fae0b4}
@media(max-height:480px){#rec-replay{padding:8px 12px}#rec-replay h2{font-size:18px;margin:0 0 6px}#rec-replay .dialog-kicker{display:none}#rec-replay video{max-height:calc(100vh - 112px)}.rp-bar{margin-top:6px}.rp-bar button{padding:6px;font-size:14px}}
@media(prefers-reduced-motion:reduce){.rec-badge i,.rec-toast,.rs-rec .rec-b .rec-ic{animation:none!important}.rec-flash{display:none}}
html.acc-calm .rec-badge i,html.acc-calm .rec-toast,html.acc-calm .rs-rec .rec-b .rec-ic{animation:none!important}
html.acc-calm .rec-flash{display:none}

441
record.js Normal file
View File

@ -0,0 +1,441 @@
/* Rekam ulang K.O. dan ekspor tangkapan layar (RENCANA 7.3 dan 7.4). Gaya: record.css. Seluruh logika ada di berkas ini; game.js tidak diubah.
Kait di berkas lain: vs.js (ResultView.view memanggil window.Record?.resultHtml(hasil) di dalam .rs-main, ResultView.mount memanggil window.Record?.mount(root))
dan satu baris <link>/<script> di index.html. Keadaan permainan dibaca dari DOM, bukan dari game.js: body.in-menu, atribut #arena data-mode / data-match-phase /
data-character / data-opponent (diisi game.js tiap frame di updateHud), dialog[open] (jeda dan pengaturan), VS.active dan Story.active, document.hidden. Dibaca tiap
150 ms (poll). Bila atribut itu suatu hari hilang, perekaman diam-diam tidak jalan dan tangkapan layar tetap bisa dipakai.
Rekam ulang (7.3): kanvas salinan kecil (maks 854 px, 640 px di layar sentuh) diisi dari #arena sekitar 30 kali per detik oleh rAF sendiri, lalu captureStream(30) -> MediaRecorder
(webm vp8, cadangan vp9/webm; tanpa audio, 1,5 Mbps, 0,9 Mbps di layar sentuh). Hanya potongan pertama sebuah rekaman WebM yang memuat header dan keyframe, jadi "membuang potongan
lama" membuat file tidak bisa diputar. Karena itu dipakai DUA PEREKAM BERGANTIAN: perekam baru dimulai tiap PERIOD (6 dtk waktu rekam), masing-masing berhenti sesudah 2 x PERIOD
dan isinya dibuang. Klip = perekam tertua (6 sampai 12 dtk), selalu satu berkas WebM utuh, tanpa mengurai atau menyambung kontainer. Memori terbatas: dua perekam x maks 12 dtk
(sekitar 2 MB masing-masing) ditambah satu klip jadi dan satu bingkai terakhir arena. Jeda, Pengaturan, layar VS/kartu cerita dan tab tersembunyi memanggil pause() (jam rekam ikut
berhenti, jadi klip tidak berisi bingkai beku); menu atau pertandingan yang dibatalkan membuang semuanya. Saat matchPhase menjadi 'complete' (atau ResultView.view lebih dulu, karena
layar hasil dipasang pada tick yang sama dengan event 'complete') perekam tertua dihentikan menjadi klip dan bingkai terakhir arena dibekukan untuk tombol FOTO di layar hasil.
Klip dibuang saat pertandingan berikutnya mulai. MediaRecorder atau tipe webm tidak ada, atau ada galat: fitur klip hilang tanpa suara (tangkapan layar tetap jalan).
Tangkapan layar (7.4): kanvas salinan + watermark KONOHA FIGHTING di pojok kanan bawah -> PNG. Tombol kamera di HUD (.match-actions), baris di menu jeda, tombol FOTO dan BAGIKAN di layar
hasil, pintasan F9 (bukan tombol game, tidak bisa diubah-petakan oleh input.js). BAGIKAN memakai navigator.share dengan berkas bila bisa, selain itu mengunduh.
Simpanan: localStorage['konoha.record.v1'] = {on, badge}. Konsol: Record.state(), Record.set(false). Tidak memakai Date.now/Math.random. */
(() => {
'use strict';
const arena = document.getElementById('arena');
if (!arena) return;
const $ = s => document.querySelector(s);
const warn = e => { try { console.warn('Record:', e && e.message ? e.message : e); } catch (_) { /* konsol tidak tersedia */ } };
const store = { get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* penyimpanan diblokir */ } } };
const KEY = 'konoha.record.v1';
const cfg = (() => { let d = {}; try { d = JSON.parse(store.get(KEY) || '{}') || {}; } catch (_) { /* simpanan rusak: pakai bawaan */ } return { on: d.on !== false, badge: d.badge !== false }; })();
const saveCfg = () => store.set(KEY, JSON.stringify({ on: cfg.on, badge: cfg.badge }));
const FPS = 30, PERIOD = 6000, POLL = 150, MIN_MS = 1500, MIN_BYTES = 3000, MAX_SHOT = 1920;
const TYPES = ['video/webm;codecs=vp8', 'video/webm;codecs=vp9', 'video/webm'];
const MIME = (() => {
try {
if (typeof arena.captureStream !== 'function' || typeof MediaRecorder !== 'function' || typeof MediaRecorder.isTypeSupported !== 'function') return '';
return TYPES.find(t => MediaRecorder.isTypeSupported(t)) || '';
} catch (_) { return ''; }
})();
// state: idle (belum merekam) | run | hold (dijeda) | done (pertandingan selesai, klip diproses atau siap). slots = perekam yang berjalan (paling banyak dua), tertua dulu.
const S = { rc: null, rx: null, stream: null, bps: 1500000, slots: [], state: 'idle', fin: false, clip: null, frame: null, raf: 0, last: 0, t: performance.now(), phase: '', menu: true, stale: false, dead: false, badge: null, seq: 0 };
const slug = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
function fileName(ext, frozen) {
const ds = arena.dataset, w = frozen && S.clip ? S.clip : null, a = slug(w ? w.player : ds.character) || 'petarung', b = slug(w ? w.enemy : ds.opponent) || 'lawan';
return `konoha-fighting-${a}${w || ds.mode === 'versus' ? '-vs-' + b : ''}.${ext}`;
}
function save(blob, name) {
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url; a.download = name; a.rel = 'noopener'; a.hidden = true;
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 20000);
}
// Umpan balik di atas dialog modal harus berada di dalam dialog itu (lapisan atas), kalau tidak tersembunyi di belakang latar dialog.
function toast(text, bad) {
const open = document.querySelectorAll('dialog[open]'), host = open.length ? open[open.length - 1] : document.body;
const old = document.getElementById('rec-toast'); if (old) old.remove();
const el = document.createElement('div'); el.id = 'rec-toast'; el.className = 'rec-toast' + (bad ? ' bad' : ''); el.setAttribute('role', 'status'); el.textContent = text;
host.append(el); clearTimeout(toast.t); toast.t = setTimeout(() => el.remove(), 2400);
}
function shutter(b) {
try { window.__game?.menuSound?.('blink'); } catch (_) { /* audio belum aktif */ }
if (b && b.id === 'rec-shot') { b.classList.add('rec-done'); setTimeout(() => b.classList.remove('rec-done'), 700); }
if (window.Access?.reduced || document.querySelector('dialog[open]')) return;
const f = document.createElement('div'); f.className = 'rec-flash'; document.body.append(f); setTimeout(() => f.remove(), 340);
}
const refocus = () => { if (!document.querySelector('dialog[open]') && !document.body.classList.contains('in-menu')) arena.focus({ preventScroll: true }); }; // Spasi di tombol HUD akan menekan tombolnya, bukan menyerang
// ---- tangkapan layar ----
function mark(g, w, h) {
const unit = Math.min(w, h * 16 / 9), fs = Math.max(13, Math.round(unit * .019)), pad = Math.round(fs * .7), gap = Math.round(fs * .5), dia = fs * .62, text = 'KONOHA FIGHTING';
g.save();
g.font = `700 ${fs}px Rajdhani,"Arial Narrow",Arial,sans-serif`; g.textBaseline = 'alphabetic';
try { g.letterSpacing = Math.max(1, Math.round(fs * .16)) + 'px'; } catch (_) { /* tanpa jarak huruf */ }
const tw = g.measureText(text).width, bw = pad * 2 + dia + gap + tw, bh = fs + pad * 1.4, x = w - bw - Math.round(fs * .9), y = h - bh - Math.round(fs * .9), cx = x + pad + dia / 2, cy = y + bh / 2;
g.fillStyle = 'rgba(8,19,28,.68)'; g.fillRect(x, y, bw, bh);
g.fillStyle = '#e6c78f'; g.fillRect(x, y, Math.max(2, fs * .12), bh);
g.beginPath(); g.moveTo(cx, cy - dia / 2); g.lineTo(cx + dia / 2, cy); g.lineTo(cx, cy + dia / 2); g.lineTo(cx - dia / 2, cy); g.closePath(); g.fill();
g.fillStyle = '#fff3d4'; g.shadowColor = 'rgba(0,0,0,.6)'; g.shadowBlur = fs * .25; g.fillText(text, x + pad + dia + gap, cy + fs * .34);
g.restore();
}
// frozen = layar hasil: pakai bingkai terakhir pertandingan bila ada (arena di belakang menu sudah berubah), selain itu kanvas arena yang sekarang.
function shotCanvas(frozen) {
const src = frozen && S.frame && S.frame.width ? S.frame : arena, sw = src.width, sh = src.height; if (!sw || !sh) return null;
const k = Math.min(1, MAX_SHOT / sw), w = Math.round(sw * k), h = Math.round(sh * k), c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d'); g.imageSmoothingQuality = 'high'; g.drawImage(src, 0, 0, sw, sh, 0, 0, w, h); mark(g, w, h);
return c;
}
// Sinkron (tanpa menunggu) supaya navigator.share masih berada di dalam gestur klik; dipakai hanya untuk BAGIKAN.
function blobSync(c) {
const u = c.toDataURL('image/png'), b = atob(u.slice(u.indexOf(',') + 1)), a = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) a[i] = b.charCodeAt(i);
return new Blob([a], { type: 'image/png' });
}
let busy = false;
async function shotSave(b, frozen) {
if (busy) return; busy = true;
try {
const c = shotCanvas(frozen); if (!c) { toast('Arena belum siap', true); return; }
const blob = await new Promise((res, rej) => c.toBlob(x => (x ? res(x) : rej(new Error('PNG kosong'))), 'image/png'));
c.width = c.height = 0;
save(blob, fileName('png', frozen)); shutter(b); toast('Tangkapan layar disimpan');
} catch (e) { warn(e); toast(e && e.name === 'SecurityError' ? 'Tidak bisa menyimpan dari berkas lokal, buka lewat server' : 'Gagal menyimpan tangkapan layar', true); }
finally { busy = false; }
}
function shotShare(b, frozen) {
if (busy) return; busy = true;
const done = () => { busy = false; };
try {
const c = shotCanvas(frozen); if (!c) { toast('Arena belum siap', true); done(); return; }
const blob = blobSync(c), name = fileName('png', frozen); c.width = c.height = 0;
let file = null; try { file = new File([blob], name, { type: 'image/png' }); } catch (_) { /* tanpa konstruktor File */ }
let can = false; try { can = !!(file && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })); } catch (_) { /* canShare tanpa dukungan berkas */ }
if (can) {
navigator.share({ files: [file], title: 'Konoha Fighting', text: 'Tangkapan layar dari Konoha Fighting' })
.then(() => toast('Dibagikan'), e => { if (e && e.name === 'AbortError') return; save(blob, name); toast('Berbagi gagal, gambar diunduh'); })
.then(done, done);
} else { save(blob, name); toast('Berbagi tidak tersedia, gambar diunduh'); done(); }
} catch (e) { warn(e); toast('Gagal membagikan', true); done(); }
}
function grabFrame() {
freeFrame();
try {
const sw = arena.width, sh = arena.height; if (!sw || !sh) return;
const k = Math.min(1, MAX_SHOT / sw), c = document.createElement('canvas'); c.width = Math.round(sw * k); c.height = Math.round(sh * k);
c.getContext('2d').drawImage(arena, 0, 0, sw, sh, 0, 0, c.width, c.height); S.frame = c;
} catch (_) { S.frame = null; }
}
function freeFrame() { if (S.frame) { S.frame.width = S.frame.height = 0; S.frame = null; } }
// ---- perekam bergulir ----
function dropRec() {
try { if (S.stream) S.stream.getTracks().forEach(t => t.stop()); } catch (_) { /* aliran sudah berhenti */ }
if (S.rc) S.rc.width = S.rc.height = 0;
S.rc = S.rx = S.stream = null;
}
function ensureRec() {
const sw = arena.width, sh = arena.height; if (!(sw > 0 && sh > 0)) return false;
const maxW = document.documentElement.classList.contains('touch') ? 640 : 854;
const w = Math.min(maxW, sw) & ~1, h = Math.round(w * sh / sw) & ~1; if (w < 64 || h < 64) return false;
if (S.rc && (Math.abs(S.rc.width - w) > 96 || Math.abs(S.rc.width / S.rc.height - w / h) > .08)) dropRec(); // jendela berubah antar pertandingan: geometri baru
if (!S.rc) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const x = c.getContext('2d', { alpha: false }); if (!x) return false;
x.imageSmoothingEnabled = true; x.imageSmoothingQuality = 'medium'; x.fillStyle = '#000'; x.fillRect(0, 0, w, h);
S.stream = c.captureStream(); S.rc = c; S.rx = x; // tanpa batas bingkai: pump di bawah sudah membatasi ke ~30/dtk, batas ganda bisa membuang bingkai secara tidak merata
}
return true;
}
// Letterbox bila rasio jendela berubah di tengah pertandingan (ukuran video tetap sepanjang sesi supaya penyandi tidak berganti resolusi).
function paint() {
const w = arena.width, h = arena.height, rc = S.rc; if (!rc || !w || !h) return;
const k = Math.min(rc.width / w, rc.height / h), dw = Math.round(w * k), dh = Math.round(h * k);
if (dw < rc.width - 1 || dh < rc.height - 1) { S.rx.fillStyle = '#000'; S.rx.fillRect(0, 0, rc.width, rc.height); }
S.rx.drawImage(arena, 0, 0, w, h, (rc.width - dw) >> 1, (rc.height - dh) >> 1, dw, dh);
}
function pump(ts) {
S.raf = 0;
if (S.state !== 'run') return;
S.raf = requestAnimationFrame(pump);
if (ts - S.last < 1000 / FPS - 4) return; // layar 60 Hz: tiap bingkai kedua; layar lebih cepat: sedikit di bawah 30
S.last = ts;
try { paint(); } catch (e) { fail(e); }
}
const startPump = () => { if (!S.raf) { S.last = 0; S.raf = requestAnimationFrame(pump); } };
const stopPump = () => { if (S.raf) { cancelAnimationFrame(S.raf); S.raf = 0; } };
function addSlot() {
const mr = new MediaRecorder(S.stream, { mimeType: MIME, videoBitsPerSecond: S.bps }), slot = { mr, chunks: [], age: 0 };
mr.ondataavailable = e => { if (e.data && e.data.size) slot.chunks.push(e.data); };
mr.onerror = () => { if (S.slots.includes(slot)) fail(new Error('MediaRecorder')); };
mr.start(1000);
S.slots.push(slot);
}
function killSlot(s) {
const mr = s.mr; s.chunks = [];
mr.ondataavailable = mr.onerror = mr.onstop = null;
try { if (mr.state !== 'inactive') mr.stop(); } catch (_) { /* sudah berhenti */ }
}
const discardSlots = () => { for (const s of S.slots.splice(0)) killSlot(s); };
function start() {
try {
if (!ensureRec()) return false;
S.bps = document.documentElement.classList.contains('touch') ? 900000 : 1500000;
paint(); addSlot(); return true;
} catch (e) { fail(e); return false; }
}
function run(dt) {
if (S.state === 'hold') resumeAll();
if (!S.slots.length) { if (!start()) return; dt = 0; }
S.state = 'run'; startPump(); badge();
for (const s of S.slots) s.age += dt;
const a = S.slots[0], b = S.slots[1];
try {
if (!b && a.age >= PERIOD) addSlot(); // perekam kedua mulai saat yang pertama sudah punya PERIOD
else if (b && a.age >= 2 * PERIOD) killSlot(S.slots.shift()); // yang tertua berhenti dan dibuang; yang kedua jadi tertua (>= PERIOD), penggantinya mulai di poll berikutnya
} catch (e) { fail(e); }
}
function hold() {
if (S.state !== 'run') return;
S.state = 'hold'; stopPump(); badge();
for (const s of S.slots) { try { if (s.mr.state === 'recording') s.mr.pause(); } catch (_) { /* pause tidak didukung: bingkai tetap tidak masuk karena pump berhenti */ } }
}
function resumeAll() { for (const s of S.slots) { try { if (s.mr.state === 'paused') s.mr.resume(); } catch (_) { /* biarkan */ } } }
const abandon = () => { discardSlots(); stopPump(); S.state = 'idle'; badge(); };
function clearClip() {
const c = S.clip; S.clip = null;
if (c && c.url) { try { URL.revokeObjectURL(c.url); } catch (_) { /* tidak ada yang perlu dilepas */ } }
freeFrame(); paintUi();
}
function reset() { discardSlots(); stopPump(); S.state = 'idle'; S.fin = false; clearClip(); badge(); }
function collect(s) {
return new Promise(resolve => {
const mr = s.mr; let over = false, timer = 0;
const end = () => {
if (over) return; over = true; clearTimeout(timer);
mr.ondataavailable = mr.onstop = mr.onerror = null;
const chunks = s.chunks; s.chunks = [];
resolve(chunks.length ? new Blob(chunks, { type: 'video/webm' }) : null);
};
mr.onstop = end; mr.onerror = end; timer = setTimeout(end, 2500); // 'stop' datang sesudah dataavailable terakhir; batas waktu untuk jaga-jaga
try { if (mr.state === 'inactive') end(); else mr.stop(); } catch (_) { end(); }
});
}
// Pertandingan selesai: perekam tertua jadi klip (async), yang lain dibuang, bingkai terakhir dibekukan. Aman dipanggil berulang.
function finish() {
if (S.fin) return S.clip;
S.fin = true; stopPump();
const ds = arena.dataset, slots = S.slots.splice(0), old = slots.shift();
slots.forEach(killSlot);
const clip = S.clip = { id: ++S.seq, state: 'none', blob: null, url: '', player: ds.character || '', enemy: ds.opponent || '', dur: 0, ready: Promise.resolve() };
grabFrame(); S.state = 'done'; badge();
if (old && old.age >= MIN_MS) {
clip.state = 'wait'; clip.dur = Math.round(old.age) / 1000;
clip.ready = collect(old).then(blob => {
if (S.clip !== clip) return;
if (blob && blob.size >= MIN_BYTES) { clip.blob = blob; clip.state = 'ready'; } else clip.state = 'none';
paintUi();
}).catch(e => { warn(e); if (S.clip === clip) { clip.state = 'none'; paintUi(); } });
} else if (old) killSlot(old);
return clip;
}
function fail(e) {
warn(e); S.dead = true;
try { reset(); dropRec(); } catch (_) { /* pembersihan terbaik yang bisa */ }
for (const id of ['rec-block', 'rec-badge']) { const el = document.getElementById(id); if (el) el.remove(); }
S.badge = null; paintUi();
}
function badge() { if (S.badge) S.badge.hidden = !(cfg.badge && S.state === 'run'); }
// ---- pengawas: tiap 150 ms membaca keadaan permainan dari DOM ----
function poll() {
if (S.dead) return;
try {
const now = performance.now(), dt = Math.min(now - S.t, 500); S.t = now;
const ds = arena.dataset, menu = document.body.classList.contains('in-menu'), phase = ds.matchPhase || '', versus = ds.mode === 'versus';
const left = S.menu && !menu; S.menu = menu;
// Menu terbuka (termasuk layar hasil): rekaman yang belum selesai dibuang; klip yang sudah jadi tetap untuk layar hasil.
if (menu) { if (!S.fin) abandon(); return; }
const prev = S.phase; S.phase = phase;
// Pertandingan baru: menu depan menutup, atau Arcade/Survival lanjut tanpa menu (complete -> intro), atau mulai ulang dari jeda (fight -> intro).
if (left) { reset(); S.stale = true; } else if (phase === 'intro' && (prev === 'complete' || prev === 'fight')) reset();
if (S.stale) { if (phase === 'complete') return; S.stale = false; } // atribut #arena masih sisa pertandingan lama selama satu frame
if (!versus) { if (S.slots.length || S.clip || S.fin) reset(); return; }
if (phase === 'complete') { if (!S.fin) finish(); return; }
if (S.fin) return;
const modal = !!document.querySelector('dialog[open]'), cards = !!(window.VS && window.VS.active) || !!(window.Story && window.Story.active);
if (cfg.on && MIME && !document.hidden && !modal && !cards && (phase === 'intro' || phase === 'fight' || phase === 'ko')) run(dt); else hold();
} catch (e) { fail(e); }
}
// ---- layar hasil (kait vs.js) ----
const IC = {
cam: '<path d="M4 8h3l1.6-2.2h6.8L17 8h3v11H4z"/><circle cx="12" cy="13.2" r="3.4"/>',
dl: '<path d="M12 4v10m-4-3.6 4 4 4-4M5 19h14"/>',
play: '<path d="M8 5.5v13l10.5-6.5z"/>',
share: '<circle cx="6.5" cy="12" r="2.3"/><circle cx="17.5" cy="6.2" r="2.3"/><circle cx="17.5" cy="17.8" r="2.3"/><path d="m8.6 10.9 6.9-3.7m-6.9 5.9 6.9 3.7"/>'
};
const svg = k => `<svg class="rec-ic" viewBox="0 0 24 24" aria-hidden="true">${IC[k]}</svg>`;
const btn = (kind, icon, label, title, extra = '') => `<button type="button" class="rec-b" data-rec="${kind}" aria-label="${label}"${title ? ` title="${title}"` : ''}${extra}>${svg(icon)}<span class="t">${label}</span></button>`;
function bar(c) {
const has = !!c && c.state !== 'none', x = has && c.state !== 'ready' ? ' disabled aria-busy="true"' : '';
return '<div class="rs-rec" role="group" aria-label="Klip dan tangkapan layar">'
+ (has ? btn('clip', 'dl', 'SIMPAN KLIP', 'Simpan klip K.O. (.webm)', x) + btn('replay', 'play', 'TONTON ULANG', 'Putar ulang klip K.O.', x) : '')
+ btn('shot', 'cam', 'FOTO', 'Simpan tangkapan layar (F9)') + btn('share', 'share', 'BAGIKAN', 'Bagikan tangkapan layar') + '</div>';
}
function resultHtml(r) {
try {
if (!S.fin) finish(); // 'complete' dan layar hasil terjadi pada tick yang sama, sebelum poll berikutnya
const c = S.clip;
return bar(c && r && c.player === r.player && c.enemy === r.enemy ? c : null);
} catch (e) { warn(e); return ''; }
}
function paintUi() {
const st = S.clip ? S.clip.state : 'none';
document.querySelectorAll('.rs-rec').forEach(box => box.querySelectorAll('[data-rec="clip"],[data-rec="replay"]').forEach(b => {
if (st === 'none') { b.remove(); return; }
b.disabled = st !== 'ready'; if (st === 'ready') b.removeAttribute('aria-busy'); else b.setAttribute('aria-busy', 'true');
}));
}
function act(kind, b, frozen) {
try {
if (kind === 'shot') shotSave(b, frozen); else if (kind === 'share') shotShare(b, frozen);
else if (kind === 'clip') clipSave(); else if (kind === 'replay') replayOpen();
} catch (e) { warn(e); }
}
function mount(root) {
try {
const box = root && root.querySelector && root.querySelector('.rs-rec'); if (!box) return;
box.addEventListener('click', e => { const b = e.target.closest('[data-rec]'); if (b && !b.disabled) act(b.dataset.rec, b, true); });
if (S.clip && S.clip.state === 'wait') S.clip.ready.then(paintUi);
} catch (e) { warn(e); }
}
function clipSave() {
const c = S.clip; if (!c || c.state !== 'ready' || !c.blob) return;
save(c.blob, fileName('webm', true)); toast('Klip disimpan');
}
// ---- Tonton ulang: dialog modal (Esc, tombol B gamepad, dan fokus ditangani seperti dialog lain) ----
function replayDialog() {
let d = document.getElementById('rec-replay'); if (d) return d;
d = document.createElement('dialog'); d.id = 'rec-replay'; d.setAttribute('aria-labelledby', 'rec-replay-t');
d.innerHTML = '<div class="dialog-kicker">KONOHA FIGHTING / K.O.</div><h2 id="rec-replay-t">TONTON ULANG</h2><video playsinline muted preload="auto"></video>'
+ '<div class="rp-bar"><button type="button" data-rp="toggle">JEDA</button><button type="button" data-rp="again">ULANGI</button><button type="button" data-rp="save">SIMPAN KLIP</button><button type="button" data-rp="close">TUTUP</button></div>';
document.body.append(d);
const v = d.querySelector('video'), t = d.querySelector('[data-rp="toggle"]');
const sync = () => { t.textContent = v.paused || v.ended ? 'PUTAR' : 'JEDA'; };
v.addEventListener('play', sync); v.addEventListener('pause', sync); v.addEventListener('ended', sync);
const toggle = () => { if (v.paused || v.ended) { const p = v.play(); if (p && p.catch) p.catch(() => {}); } else v.pause(); };
v.addEventListener('click', toggle);
d.addEventListener('click', e => {
if (e.target === d) { const r = d.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) replayClose(); return; } // klik di latar, bukan di padding dialog
const k = e.target.closest('[data-rp]') && e.target.closest('[data-rp]').dataset.rp;
if (k === 'toggle') toggle();
else if (k === 'again') { v.currentTime = 0; const p = v.play(); if (p && p.catch) p.catch(() => {}); }
else if (k === 'save') clipSave();
else if (k === 'close') replayClose();
});
d.addEventListener('cancel', e => { e.preventDefault(); replayClose(); }); // Esc e tombol B gamepad (input.js mengirim 'cancel')
return d;
}
function replayOpen() {
const c = S.clip; if (!c || c.state !== 'ready' || !c.blob) return;
const d = replayDialog(), v = d.querySelector('video');
d.querySelector('.dialog-kicker').textContent = 'KONOHA FIGHTING / K.O.' + (c.dur >= 1 ? ' · ' + Math.round(c.dur) + ' DETIK' : '');
if (!c.url) c.url = URL.createObjectURL(c.blob);
v.src = c.url; v.muted = true; try { v.currentTime = 0; } catch (_) { /* belum ada metadata */ }
if (!d.open) d.showModal();
const p = v.play(); if (p && p.catch) p.catch(() => {});
}
function replayClose() {
const d = document.getElementById('rec-replay'); if (!d) return;
const v = d.querySelector('video');
try { v.pause(); } catch (_) { /* tidak ada yang diputar */ }
if (d.open) d.close();
v.removeAttribute('src'); try { v.load(); } catch (_) { /* biarkan */ }
}
// ---- pasang elemen: HUD, jeda, Pengaturan ----
function mountHud() {
const nav = $('.match-actions'); if (!nav || nav.querySelector('#rec-shot')) return;
const b = document.createElement('button'); b.type = 'button'; b.id = 'rec-shot'; b.setAttribute('aria-label', 'Tangkap layar (F9)'); b.title = 'Tangkap layar (F9)';
b.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${IC.cam}</svg>`;
b.onclick = () => { shotSave(b, false); refocus(); };
nav.append(b);
if (MIME) {
const t = document.createElement('span'); t.id = 'rec-badge'; t.className = 'rec-badge'; t.hidden = true; t.setAttribute('aria-hidden', 'true'); t.innerHTML = '<i></i>REC';
nav.append(t); S.badge = t;
}
}
function mountPause() {
const main = $('#pause-panel .pause-main'); if (!main || main.querySelector('.rec-btns')) return;
const row = document.createElement('div'); row.className = 'rec-btns';
row.innerHTML = btn('shot', 'cam', 'TANGKAP LAYAR', 'Simpan tangkapan layar (F9)') + btn('share', 'share', 'BAGIKAN', 'Bagikan tangkapan layar');
row.addEventListener('click', e => { const b = e.target.closest('[data-rec]'); if (b) act(b.dataset.rec, b, false); });
main.append(row);
}
function mountSettings() {
const panel = $('#settings-panel'); if (!panel || !MIME || panel.querySelector('#rec-row')) return;
const box = document.createElement('div'); box.id = 'rec-block';
box.innerHTML = '<label class="setting-row" id="rec-row"><span>Rekam ulang K.O.<small>Menyimpan sekitar 6 sampai 12 detik terakhir tiap pertandingan untuk klip di layar hasil, hanya di perangkat ini</small></span><input type="checkbox" id="rec-toggle"></label>'
+ '<label class="setting-row" id="rec-badge-row"><span>Indikator REC<small>Titik merah kecil di bawah tombol arena saat merekam</small></span><input type="checkbox" id="rec-badge-toggle"></label>';
const vol = panel.querySelector('#volume-slider'), anchor = vol && vol.closest('.volume-row');
if (anchor) anchor.after(box); else (panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(box);
const on = box.querySelector('#rec-toggle'), bd = box.querySelector('#rec-badge-toggle');
on.checked = cfg.on; bd.checked = cfg.badge; bd.disabled = !cfg.on;
on.onchange = () => {
cfg.on = on.checked; bd.disabled = !cfg.on; saveCfg();
if (!cfg.on) { discardSlots(); stopPump(); S.state = S.fin ? 'done' : 'idle'; } // klip yang sudah jadi tidak dibuang
badge();
};
bd.onchange = () => { cfg.badge = bd.checked; saveCfg(); badge(); };
}
// F9 = tangkapan layar di arena (termasuk saat dijeda) dan di layar hasil; di tempat lain dibiarkan untuk peramban. Esc menutup Tonton ulang lebih dulu daripada menu di belakangnya.
function place() {
if (!arena.width) return '';
if (!document.body.classList.contains('in-menu')) return window.VS?.active || window.Story?.active ? '' : 'arena';
const fm = $('#front-menu');
return fm && fm.dataset.screen === 'result' && !document.querySelector('dialog[open]') ? 'result' : '';
}
window.addEventListener('keydown', e => {
const d = document.getElementById('rec-replay');
if (d && d.open) {
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); replayClose(); }
else if (e.key === ' ' && e.target.tagName !== 'BUTTON') { e.preventDefault(); e.stopImmediatePropagation(); d.querySelector('[data-rp="toggle"]').click(); }
return;
}
if (e.key !== 'F9' || e.repeat || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const where = place(); if (!where) return;
e.preventDefault(); e.stopImmediatePropagation();
shotSave(null, where === 'result');
}, true);
// Pertandingan baru memulai sesi baru seketika (poll hanya cadangan): tanpa ini hasil yang dipasang sebelum poll berikutnya bisa memakai klip pertandingan sebelumnya.
function hookStart() {
const g = window.__game; if (!g || typeof g.startMatch !== 'function' || g.startMatch.__rec) return;
const base = g.startMatch;
const wrapped = function () {
const ok = base.apply(this, arguments);
try { if (ok) { reset(); S.stale = true; } } catch (_) { /* tidak boleh mengganggu mulai pertandingan */ }
return ok;
};
wrapped.__rec = true; g.startMatch = wrapped;
}
function init() {
try { mountHud(); mountPause(); mountSettings(); hookStart(); } catch (e) { warn(e); }
S.t = performance.now();
setInterval(poll, POLL);
document.addEventListener('visibilitychange', poll);
window.addEventListener('pagehide', () => { try { reset(); } catch (_) { /* halaman sedang ditutup */ } });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
window.Record = {
get supported() { return !!MIME && !S.dead; },
get enabled() { return cfg.on; },
set(on) { cfg.on = !!on; saveCfg(); const t = $('#rec-toggle'); if (t) { t.checked = cfg.on; t.onchange(); } return cfg.on; },
state() { return { supported: !!MIME, dead: S.dead, on: cfg.on, mime: MIME, state: S.state, fin: S.fin, ages: S.slots.map(s => Math.round(s.age)), clip: S.clip && { state: S.clip.state, dur: S.clip.dur, bytes: S.clip.blob ? S.clip.blob.size : 0, player: S.clip.player, enemy: S.clip.enemy } }; },
resultHtml, mount,
shot(frozen) { return shotSave(null, !!frozen); },
share(frozen) { return shotShare(null, !!frozen); },
saveClip: clipSave, replay: replayOpen
};
})();

6
vs.js
View File

@ -2,6 +2,7 @@
- window.VS.show(info, onDone): overlay sekitar 2,2 detik sebelum ronde 1. onDone dipanggil tepat satu kali (waktu habis atau dilewati - window.VS.show(info, onDone): overlay sekitar 2,2 detik sebelum ronde 1. onDone dipanggil tepat satu kali (waktu habis atau dilewati
dengan klik/Spasi/Enter/Esc); setelah itu game memulai urutan cue ronde seperti biasa (game.js startMatch -> emitRoundCue). dengan klik/Spasi/Enter/Esc); setelah itu game memulai urutan cue ronde seperti biasa (game.js startMatch -> emitRoundCue).
- window.ResultView.view(hasil, ctx) -> {title, eyebrow, html}; ResultView.mount(root) menghidupkan hitungan koin dan memberi fokus ke wadah layar. - window.ResultView.view(hasil, ctx) -> {title, eyebrow, html}; ResultView.mount(root) menghidupkan hitungan koin dan memberi fokus ke wadah layar.
Modul lain yang menambah isi layar hasil lewat kait kecil: MatchStats (stats.js) dan Record (record.js: klip K.O., tangkapan layar, BAGIKAN).
- Dialog pasangan (RENCANA 3.1, content/dialogs.js): bila pasangan punya dialog, kutipan pembuka diganti dua balon ucapan (kiri = pemain, kanan = lawan) - 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 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). berlabel DIALOG RAHASIA dan memanggil Meta.pairEgg (koin dan toast sekali per pasangan, pencapaian REUNI).
@ -124,6 +125,8 @@
const stat = (k, v) => `<div><dt>${k}</dt><dd>${esc(v)}</dd></div>`; const stat = (k, v) => `<div><dt>${k}</dt><dd>${esc(v)}</dd></div>`;
// Statistik pertandingan (stats.js, RENCANA 5.5): tombol di kepala dan kartu lipat; kosong bila pertandingan tanpa statistik (mis. data uji). // Statistik pertandingan (stats.js, RENCANA 5.5): tombol di kepala dan kartu lipat; kosong bila pertandingan tanpa statistik (mis. data uji).
const ms = window.MatchStats?.resultHtml(r.stats, { id: r.player, name: fighter(r.player).name, last: !!run }) || { btn: '', panel: '' }; const ms = window.MatchStats?.resultHtml(r.stats, { id: r.player, name: fighter(r.player).name, last: !!run }) || { btn: '', panel: '' };
// Klip K.O., tangkapan layar, dan BAGIKAN (record.js, RENCANA 7.3/7.4): toolbar melayang di pojok kanan atas .rs-main; kosong bila modul tidak ada.
const rec = window.Record?.resultHtml(r) || '';
const combo = r.matchBest ?? r.bestCombo ?? 0; const combo = r.matchBest ?? r.bestCombo ?? 0;
const stats = run const stats = run
? stat(run.kind === 'arcade' ? 'Lawan' : 'Menang beruntun', run.kind === 'arcade' ? `${run.wins} / ${run.total}` : String(run.wins)) + (boss ? stat('Bayangan', boss === 'win' ? 'Dikalahkan' : 'Bertahan') : stat(run.kind === 'arcade' ? 'Tingkat' : 'Rekor', run.kind === 'arcade' ? lv : String(run.best))) + stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde terakhir', `${r.playerWins} – ${r.enemyWins}`) ? stat(run.kind === 'arcade' ? 'Lawan' : 'Menang beruntun', run.kind === 'arcade' ? `${run.wins} / ${run.total}` : String(run.wins)) + (boss ? stat('Bayangan', boss === 'win' ? 'Dikalahkan' : 'Bertahan') : stat(run.kind === 'arcade' ? 'Tingkat' : 'Rekor', run.kind === 'arcade' ? lv : String(run.best))) + stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde terakhir', `${r.playerWins} – ${r.enemyWins}`)
@ -134,7 +137,7 @@
+ `<div class="rs-main"><div class="rs-head"><small class="rs-kicker">${esc(kicker)}</small><h1>${title}</h1>${scoreHtml}${ms.btn}</div>` + `<div class="rs-main"><div class="rs-head"><small class="rs-kicker">${esc(kicker)}</small><h1>${title}</h1>${scoreHtml}${ms.btn}</div>`
+ `<blockquote class="rs-quote"><p>&ldquo;${esc(q.win)}&rdquo;</p><cite>${esc(Wf.name)}</cite></blockquote>` + `<blockquote class="rs-quote"><p>&ldquo;${esc(q.win)}&rdquo;</p><cite>${esc(Wf.name)}</cite></blockquote>`
+ `<div class="rs-cols">${coins}<section class="rs-stats" aria-label="Statistik"><h3>STATISTIK</h3><dl>${stats}</dl></section></div>` + `<div class="rs-cols">${coins}<section class="rs-stats" aria-label="Statistik"><h3>STATISTIK</h3><dl>${stats}</dl></section></div>`
+ `<div class="rs-actions"><button class="menu-primary rs-again" data-cmd="rematch">MAIN LAGI <b>↗</b></button><button class="result-secondary" data-cmd="reselect">PILIH ULANG</button><button class="result-secondary" data-cmd="shop">KE TOKO</button><button class="result-secondary" data-cmd="home">MENU UTAMA</button></div>${ms.panel}</div>` + `<div class="rs-actions"><button class="menu-primary rs-again" data-cmd="rematch">MAIN LAGI <b>↗</b></button><button class="result-secondary" data-cmd="reselect">PILIH ULANG</button><button class="result-secondary" data-cmd="shop">KE TOKO</button><button class="result-secondary" data-cmd="home">MENU UTAMA</button></div>${ms.panel}${rec}</div>`
+ `<div class="rs-fighter lose${Lf.shadow ? ' shadow' : ''}"><img class="rs-art" src="${esc(artOf(Lf))}" alt="${esc(Lf.name)}" draggable="false"><div class="rs-plate"><small>${esc(Lf.name)}</small><p>&ldquo;${esc(q.lose)}&rdquo;</p></div></div></div>`; + `<div class="rs-fighter lose${Lf.shadow ? ' shadow' : ''}"><img class="rs-art" src="${esc(artOf(Lf))}" alt="${esc(Lf.name)}" draggable="false"><div class="rs-plate"><small>${esc(Lf.name)}</small><p>&ldquo;${esc(q.lose)}&rdquo;</p></div></div></div>`;
return { title: run ? (run.kind === 'arcade' ? 'MODE ARCADE' : 'MODE SURVIVAL') : 'PERTANDINGAN SELESAI', eyebrow: stName ? stName.toUpperCase() : 'KONOHA FIGHTING', html }; return { title: run ? (run.kind === 'arcade' ? 'MODE ARCADE' : 'MODE SURVIVAL') : 'PERTANDINGAN SELESAI', eyebrow: stName ? stName.toUpperCase() : 'KONOHA FIGHTING', html };
} }
@ -152,6 +155,7 @@
// Fokus ke wadah (bukan tombol): Spasi/Enter yang masih ditekan dari pertarungan tidak boleh langsung mengaktifkan MAIN LAGI; Tab tetap menjangkau tombol. // Fokus ke wadah (bukan tombol): Spasi/Enter yang masih ditekan dari pertarungan tidak boleh langsung mengaktifkan MAIN LAGI; Tab tetap menjangkau tombol.
if (!document.documentElement.classList.contains('touch')) { rs.tabIndex = -1; rs.focus({ preventScroll: true }); } if (!document.documentElement.classList.contains('touch')) { rs.tabIndex = -1; rs.focus({ preventScroll: true }); }
window.MatchStats?.mount(root); window.MatchStats?.mount(root);
window.Record?.mount(root);
} }
window.ResultView = { view, mount }; window.ResultView = { view, mount };
})(); })();