Gabung wf/t5-stats: statistik pertandingan

This commit is contained in:
rizkyaz0 2026-10-05 13:56:38 +07:00
commit cb7e575660
7 changed files with 202 additions and 3 deletions

View File

@ -28,6 +28,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- 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']`.
- Mode cerita Arcade dan bos Bayangan (Tahap 3.5 dan 3.6): `content/story.js` (`window.STORY`: `intro[id]` 2-3 kalimat, `between[A][B]` 1-2 kalimat (atau fungsi), `ending[id]` 3 kalimat, `boss` = pembuka, ucapan VS/kalah/menang, kalimat akhir, gelar; `generic` cadangan dengan penampung `{name}` `{rival}` `{n}`) dan `story.js` + `story.css` (`window.Story`). `Story.show({kind: intro|between|boss|ending, player, enemy, step, total, stage, boss}, onDone)` menampilkan kartu layar penuh (`#story-screen`, z-index 45, di atas layar VS 40) dan memanggil onDone tepat satu kali: klik/ketuk/Spasi/Enter/Esc setelah 0,5 dtk, atau otomatis (kartu antar-lawan sekitar 4 dtk, kartu panjang lebih lama); selama tampil semua tombol ditahan dan `Meta` menahan toast (`Story.active`). Mengembalikan false tanpa kartu bila tidak ada teks, jadi alur tetap jalan tanpa cerita. `meta.js` Arcade: `startRun` (kartu pembuka) -> `nextFight` -> menang -> `betweenFights` (kartu antar-lawan, panggung lawan berikutnya jadi latar kartu) ... lawan terakhir kalah -> `arcadeCleared` (hadiah tamat 500 + pencapaian langsung dibayar seperti dulu) -> kartu bos -> `startBoss` -> `finishRun(true, 'win'|'lose')` -> kartu akhir -> layar hasil. Survival tidak memakai cerita. `run.boss` = null | 'pending' | 'fight'. Menang atas Bayangan: +1000 koin, pencapaian BAYANGAN (+50), gelar PENAKLUK BAYANGAN (`Meta.save.title`, tampil di kartu akhir dan plat pemenang layar hasil); kalah: Arcade tetap tamat, tanpa bonus bos. Uji manual: `?arcade=N` memendekkan Arcade menjadi N lawan. Bayangan = `startMatch({player: X, enemy: X, level: 'excellent', shadow: true})` (id sama; state `hero` dan `dummy` tetap terpisah): `setShadowFoe` mengatur `BALANCE.dummyMax` (+40%), `foeDamage` menambah 10% pada damage ke pemain (pembungkus `__game.receiveHit` di akhir game.js dan `dotDamage`), `dummyHpShown` memberi skala HP dasar untuk bar dan penentu waktu habis (`MatchRules.tick`), `drawDummy` memakai `ctx.filter` gelap + lapisan ungu `drawShadowTint` + aura, percikan ungu lewat `fxr()` di `updateVisuals`. HUD: nama BAYANGAN dengan label nama asli, kelas `.shadow-foe` (story.css). VS: game.js memanggil `Story.decorateVs(info)` tepat setelah `VS.show` (nama 'BAYANGAN <NAMA>', ucapan bos); layar hasil: `ResultView.view` membaca `run.boss` (Bayangan di sisi kalah; kalah dari Bayangan tetap tampil TAMAT). `__game.shadowFoe()` melaporkan keadaan.
- Statistik pertandingan (RENCANA 5.5): `stats.js` (`window.MatchStats`) + `stats.css`; tag-nya ada di baris sendiri sebelum `</main>` di `index.html`. Statistik pemain 1 per pertandingan versus (juga 2 pemain, Arcade, Survival, Bayangan; latihan = `reset(false)`, tanpa statistik). Kait di game.js, semuanya satu baris dengan `window.MatchStats?.`: `reset(versus)` di `startMatch` dan `reset`; `tick(dt, match, hero.hp, dummy.damage, BALANCE.dummyMax)` di awal `advanceMatch` (damage keluar/diterima = selisih HP per langkah, durasi = jumlah waktu fase fight, ronde = peralihan fight -> ko); `cast(jenis, hero.action)` di `cast` dan `startAttack`; `hit(hitSource(), counter, comboHits, hero.action)` di `hitDummy` (juga jalur chip blok CPU) dan `hit(null, ...)` di burst `dotDamage`; `parry()`/`block()` di `heroGuard`; `finish(info)` di event 'complete' menaruh ringkasan di `info.stats` (dibawa `Meta.award` dan `FrontEnd.showResult`). Akurasi jurus = pemakaian yang kena sekali; pukulan proyektil/ultimate dikaitkan ke jurus terakhir dalam jendela waktu (`MatchStats.WINDOW`), jadi bila dua jurus bertumpuk bisa meleset satu. Layar hasil: `ResultView.view` memanggil `MatchStats.resultHtml` (tombol STATISTIK di kanan baris skor + kartu `.rs-ms` yang menutup kolom tengah di atas tombol aksi, diatur `MatchStats.mount`; Esc menutup kartu lebih dulu daripada kembali ke menu); tanpa `info.stats` (data uji) tidak ada tombol. Simpanan seumur hidup: `Meta.award` memanggil `MatchStats.commit(save.stats, r.stats)` (kunci baru `dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches`; `bestCombo` ikut dinaikkan ke kombo terbaik seluruh ronde; simpanan lama tanpa kunci terbaca 0), tampil di Koleksi lewat `MatchStats.lifetimeHtml`. Uji manual cepat: `MatchStats.snapshot()` di konsol saat pertandingan berjalan. Belum ada skenario otomatis khusus statistik (mode ringan, diuji manual).
- `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.

13
game.js
View File

@ -55,6 +55,8 @@
}
const cdRate = (actor, name) => passiveMod(actor, 'cd') * passiveMod(actor, name === 'ultimate' ? 'cdUlt' : 'cdSkill');
function strikeAs(kind, fn) { hitKind = kind; try { fn(); } finally { hitKind = null; } }
// Statistik pertandingan (stats.js): sumber pukulan pemain untuk MatchStats.hit; null = jurus yang sudah lepas (proyektil, ultimate), dikaitkan stats.js ke jurus terakhir.
const hitSource = () => ignoreBlock ? 'throw' : hitKind === 'basic' && hero.action?.type === 'attack' ? 'basic' : hero.action?.special ? 'special' : null;
// regen: HP kecil per detik, hanya bila aktor hidup, tidak penuh, dan tidak terkena apa pun selama 3 detik (lastHurt diisi tiap damage masuk).
function passiveRegen(dt) {
if (match && match.phase !== 'fight') return;
@ -240,6 +242,7 @@
selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY;refreshPassives(matchStyles(options,mode));
setShadowFoe(!!options.shadow&&mode==='versus');
aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins();
window.MatchStats?.reset(mode==='versus');
$('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus');
if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level,styles:{...sideStyle},shadow:shadowFoe})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3);
return true;
@ -287,6 +290,7 @@
}
function advanceMatch(dt) {
if(!Rules||!match||match.mode!=='versus'||vsHold)return;
window.MatchStats?.tick(dt,match,hero.hp,dummy.damage,BALANCE.dummyMax);
for(const event of Rules.tick(match,dt,hero.hp,dummyHpShown())) {
if(event.type==='cue')emitRoundCue(event.cue,event.round);
if(event.type==='round-end') {
@ -304,6 +308,7 @@
if(event.type==='complete'){
// bestCombo (kombo terbaik ronde terakhir) tetap dasar bonus koin; matchBest (seluruh ronde) hanya untuk layar hasil.
const info={winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,bestCombo,matchBest:Math.max(matchBest,bestCombo),p2:p2Mode,perfect:match.winner==='player'&&match.enemyWins===0,boss:shadowFoe};
info.stats=window.MatchStats?.finish(info)||null;
const metaHandled=!!window.Meta?.award?.(info);
if(!metaHandled)window.FrontEnd?.showResult({...info,mode:'versus',award:window.Meta?.lastAward||null});
}
@ -347,6 +352,7 @@
if (hero.action) { if (hero.action.type === 'attack' && hero.action.index < 3) { hero.action.queued = Math.min(3 - hero.action.index, hero.action.queued + 1); hero.action.cancelInto = null; } return false; }
const name = `attack${index}`, duration = selectedCharacter === 'fenr' ? F.move(hero,'attack',index).duration : KITS[selectedCharacter] ? KITS[selectedCharacter].move('attack',index).duration : animationDuration(name, [.39, .43, .57][index - 1]);
hero.action = { type: 'attack', index, name, t: 0, duration, hit: false, queued: 0, fired: false };
window.MatchStats?.cast('attack', hero.action);
hero.runDirection = 0; state(name); return true;
}
// Skills cancel a basic attack at any point, so the kit never waits for the combo to finish. A skill that cannot start
@ -355,6 +361,7 @@
const basic = hero.action?.type === 'attack' ? hero.action : null;
if (basic && canAct() && (hero.cooldowns[name] || 0) <= 0) hero.action = null;
const ok = castMove(name);
if (ok) window.MatchStats?.cast(name, hero.action);
if (!ok && basic && !hero.action) { hero.action = basic; state(basic.name); }
return ok;
}
@ -537,9 +544,11 @@
feelImpact('hero', 'parry', { dir: -hero.facing, x: hero.x + hero.facing * 40, y: hero.y - 100 }); damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'PARRY', kind: 'parry', life: .8, maxLife: .8, color: Feel.table.colors.parry });
spawnParticles(hero.x + hero.facing * 40, hero.y - 100, '#cfe9ff', 22, 320);
if (!['down', 'recover'].includes(dummy.state)) { dummy.action = null; dummy.state = 'hurt'; dummy.stateTime = -.3; dummy.vx = -hero.facing * 120; }
window.MatchStats?.parry();
window.Meta?.event?.('parry'); return true;
}
const chip = Math.max(1, Math.round(damage * .2)); hero.hp = Math.max(1, hero.hp - chip); hero.guard += damage * 1.6; hero.vx = -hero.facing * 90;
window.MatchStats?.block();
spawnParticles(hero.x + hero.facing * 38, hero.y - 90, '#ffd36a', 10, 200); sound('guard'); feelImpact('hero', 'guard', { dir: -hero.facing, x: hero.x + hero.facing * 38, y: hero.y - 90 });
damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'GUARD', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard });
if (hero.guard >= 60) { hero.guard = 0; hero.guardBreak = 1.2; hero.blocking = false; hero.hurtTime = .7; state('hurt'); announce('GUARD BREAK', .7); }
@ -585,6 +594,7 @@
}
function reset() {
if(Rules)match=Rules.create(match?.mode||'training');
window.MatchStats?.reset(match?.mode==='versus');
resetCombat();if(match?.mode==='versus'){announceTimer=0;emitRoundCue('round',1);}
}
function hitDummy(damage, power, color, sourceX, impactY, knockdown = false, freezeWorld = true, shake = 1) {
@ -594,6 +604,7 @@
const dir = sourceX <= dummy.x ? 1 : -1;
if (!ignoreBlock && F && aiEnabled && !p2Mode && match?.mode === 'versus' && dummy.y >= CONFIG.groundY - 1 && !dummy.action && (dummy.state === 'idle' || dummy.state === 'walk') && !knockdown && random() < (CPU_BLOCK[difficulty] ?? .1) * passiveMod(dummy, 'parry')) {
const chip = Math.min(Math.max(1, Math.round(damage * .2)), Math.max(0, BALANCE.dummyMax - dummy.damage - 1)); dummy.damage += chip; totalDamage += chip;
window.MatchStats?.hit(hitSource(), false, 0, hero.action);
spawnParticles(dummy.x - dir * 30, impactY, '#ffd36a', 8, 180); damageNumbers.push({ x: dummy.x, y: impactY - 40, amount: 'GUARD', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard }); sound('step'); feelImpact('dummy', 'guard', { dir, x: dummy.x - dir * 30, y: impactY }); return true;
}
// Counter hit: striking a rival in their wind-up deals extra; long chains scale down so none loops forever.
@ -601,6 +612,7 @@
if (counter) { damage = Math.round(damage * 1.25); if (damage > 1) announce('COUNTER!', .5); }
if (comboHits >= 5) damage = Math.max(1, Math.round(damage * Math.max(.55, 1 - .07 * (comboHits - 4))));
dummy.damage += damage; totalDamage += damage; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += damage; comboTimer = 2.3;
window.MatchStats?.hit(hitSource(), counter, comboHits, hero.action);
// Versus combo limit: after comboCap hits in one continuous stun the CPU gets its immunity (like the player's),
// so a re-started chain cannot loop it to K.O. Easy has no limit.
const cap = F && aiEnabled && match?.mode === 'versus' ? cpuProfile().comboCap : 0;
@ -1561,6 +1573,7 @@
if (target === dummy) {
dummy.damage += amount; totalDamage += amount;
if (combo) { comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboTimer = 2.3; }
if (combo) window.MatchStats?.hit(null, false, comboHits, null);
if (combo || comboTimer > 0) comboDamage += amount;
if (numbers) damageNumbers.push({ x: dummy.x + (random() - .5) * 30, y: dummy.y - 120, amount, kind: Feel.classify(amount), life: .9, maxLife: .9, color });
if (amount >= Feel.table.medium) dummy.flash = dummy.flashMax = Feel.table.hit.medium.flash;

View File

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

View File

@ -126,6 +126,7 @@
const cb = Math.min(60, (r.bestCombo || 0) * 4); if (cb) { gain += cb; add('combo', 'Bonus kombo', cb, 'Kombo', r.bestCombo + ' pukulan, ronde terakhir'); }
if (won) { save.stats.wins++; unlock('first_win'); if (save.stats.wins >= 10) unlock('wins10'); if (r.perfect) { save.stats.perfects++; unlock('perfect'); } if (r.level && r.level !== 'easy') unlock('hard_win'); } else save.stats.losses++;
save.stats.bestCombo = Math.max(save.stats.bestCombo, r.bestCombo || 0);
window.MatchStats?.commit(save.stats, r.stats); // statistik seumur hidup (kunci baru di save.stats; simpanan lama tanpa kunci dibaca 0), disimpan oleh addCoins di bawah
if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10');
const prev = run ? run.earned || 0 : 0; if (run) run.earned = prev + gain;
lastAward = { won, gain, lines, level: r.level || null, prev }; lastMatch = { ...r };
@ -273,7 +274,7 @@
function renderBook() {
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'penasaran_berat', 'logo', 'reuni'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p>${window.MatchStats?.lifetimeHtml(st) || ''}<h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
}
function onClick(e) {
const b = e.target.closest('button'); if (!b) return;

48
stats.css Normal file
View File

@ -0,0 +1,48 @@
/* Statistik pertandingan (stats.js, RENCANA 5.5): tombol dan kartu di layar hasil (vs.js ResultView), ringkasan seumur hidup di tab Koleksi (meta.js).
Kartu hasil memakai satuan vh/clamp seperti vs.css supaya muat di tinggi 520 px (dan ponsel lanskap <= 420 px); isi yang lebih tinggi dari ruang tersedia digulir di dalam kartu, tidak mendorong tombol aksi. */
/* ---- Layar hasil: tombol di kanan baris skor, kartu menutup kolom tengah di atas tombol aksi ---- */
.rs-head{position:relative}
.rs-ms-btn{position:absolute;right:0;bottom:2px;z-index:2;display:inline-flex;align-items:center;gap:8px;margin:0;padding:clamp(5px,1vh,8px) clamp(9px,1.2vw,14px);font:700 clamp(10px,1.3vh + 2px,13px)/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#f3d796;background:#0b1c26d9;border:1px solid #e0bf85aa;border-radius:0;cursor:pointer;animation:rs-up .5s .9s both}
.rs-ms-btn em{display:none;font-style:normal}
.rs-ms-btn i{display:inline-block;font-style:normal;transition:transform .18s}
.rs-ms-btn:hover,.rs-ms-btn:focus-visible{background:#24505e;color:#fff;border-color:#e0bf85}
.rs-ms-btn[aria-expanded=true] i{transform:rotate(90deg)}
@media(min-width:1180px) and (min-height:640px){.rs-ms-btn em{display:inline}}
.rs-ms{--ms-b:56px;position:absolute;left:0;right:0;top:0;bottom:var(--ms-b);z-index:9;display:flex;align-items:center;justify-content:center;padding:clamp(4px,1vh,10px) 0;background:#050f17d9;animation:ms-fade .2s ease-out both}
.rs-ms[hidden]{display:none}
.rs-ms-card{width:100%;max-height:100%;overflow:auto;padding:clamp(8px,1.5vh,14px) clamp(10px,1.6vw,16px);background:#0b1c26;border:1px solid #7fa6a533;border-top:2px solid #e0bf85;box-shadow:0 10px 30px #000a}
.rs-ms-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:clamp(5px,1.2vh,10px)}
.rs-ms-head>div{min-width:0}
.rs-ms-head small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;font-size:clamp(9px,1.2vh + 2px,12px);letter-spacing:2px;color:#8fa9ac}
.rs-ms-head h3{margin:1px 0 0;font:700 clamp(15px,2.6vh + 2px,24px)/1.05 Rajdhani,'Arial Narrow',sans-serif;letter-spacing:3px;color:#f3d796}
.rs-ms-x{flex:none;margin:0;padding:clamp(4px,.9vh,7px) clamp(8px,1.2vw,12px);font:700 clamp(10px,1.2vh + 2px,12px)/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#cbddd4;background:#183440;border:1px solid #8ba89d66;border-radius:0;cursor:pointer}
.rs-ms-x:hover,.rs-ms-x:focus-visible{background:#24505e;color:#fff}
#front-menu .rs-ms-x:focus-visible{outline-offset:-3px}
.rs-ms-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:clamp(4px,.9vh,8px);margin:0}
.rs-ms-grid>div{min-width:0;padding:clamp(4px,.9vh,8px) clamp(6px,.9vw,10px);background:#ffffff0a;border:1px solid #ffffff14;border-left:2px solid #ffffff2e}
.rs-ms-grid dt{font-weight:700;font-size:clamp(9px,1.3vh + 2px,12px);letter-spacing:1px;color:#8fa9ac;text-transform:uppercase;overflow-wrap:anywhere}
.rs-ms-grid dd{margin:0;font-weight:700;font-size:clamp(15px,2.8vh + 3px,26px);line-height:1.1;color:#f2ead3;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
.rs-ms-grid dd small{display:inline-block;margin-left:5px;font-size:.52em;font-weight:600;letter-spacing:.5px;color:#93a9ad;white-space:nowrap}
.rs-ms-grid .dmg{border-left-color:#ffe08a}.rs-ms-grid .dmg dd{color:#ffe08a}
.rs-ms-grid .hurt{border-left-color:#ff9a7a}.rs-ms-grid .hurt dd{color:#ffb4a6}
.rs-ms-grid .acc{border-left-color:#8bd4c3}
.rs-ms-grid .acc:after{content:'';display:block;height:3px;margin-top:4px;background:linear-gradient(90deg,#8bd4c3 calc(var(--p) * 1%),#ffffff1f 0)}
.rs-ms-grid .acc.none{border-left-color:#ffffff2e}.rs-ms-grid .acc.none dd{color:#6e868c}
@keyframes ms-fade{from{opacity:0}to{opacity:1}}
/* Kolom sempit atau layar pendek: kartu lebih rapat, label dan nilai lebih kecil. */
@media(max-width:700px),(max-height:420px){
.rs-ms-btn{padding:4px 8px;gap:6px;letter-spacing:1px}
.rs-ms-card{padding:6px 8px}.rs-ms-head{margin-bottom:5px}.rs-ms-head small{font-size:9px;letter-spacing:1px}.rs-ms-head h3{font-size:15px;letter-spacing:2px}
.rs-ms-grid{gap:4px}.rs-ms-grid>div{padding:3px 6px}.rs-ms-grid dt{font-size:9px;letter-spacing:.5px}.rs-ms-grid dd{font-size:15px}.rs-ms-grid dd small{font-size:9px}
.rs-ms-grid .acc:after{margin-top:2px}
}
@media(pointer:coarse){.rs-ms-btn,.rs-ms-x{min-height:30px}}
/* ---- Tab Koleksi ---- */
.ms-life{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin:12px 0 0}
.ms-life>div{min-width:0;padding:8px 12px;background:#101c26;border:1px solid #ffffff1c;border-radius:8px}
.ms-life dt{font-size:13px;letter-spacing:.06em;color:#9fb3c4;text-transform:uppercase}
.ms-life dd{margin:2px 0 0;font:700 22px/1.1 Rajdhani,Arial,sans-serif;color:#f6eedb;font-variant-numeric:tabular-nums}
.ms-life dd small{margin-left:6px;font-size:14px;font-weight:600;color:#8fa3b3}

131
stats.js Normal file
View File

@ -0,0 +1,131 @@
/* Statistik pertandingan (RENCANA 5.5). Gaya: stats.css. Seluruh logika ada di berkas ini; berkas lain hanya memanggil kait kecil:
- game.js: reset(versus) di startMatch dan reset (latihan = null, tanpa statistik); tick(dt, match, hpPemain, damageLawan, hpMaksLawan) tiap langkah advanceMatch (sebelum Rules.tick);
cast(jenis, aksi) di cast dan startAttack; hit(sumber, counter, kombo, aksi) di hitDummy dan di dotDamage (burst); parry() dan block() di heroGuard; finish(info) di event 'complete' -> info.stats.
- meta.js: commit(Meta.save.stats, info.stats) di award, lifetimeHtml(Meta.save.stats) di tab Koleksi. vs.js: resultHtml(info.stats, ctx) dan mount(root) di layar hasil.
Catatan cara hitung:
- Damage keluar/diterima dibaca dari selisih HP per langkah (HP yang benar-benar hilang: kelebihan pukulan penutup tidak dihitung, penyembuhan tidak mengurangi), jadi semua sumber damage
(pukulan, DoT, chip blok) terhitung tanpa kait di tiap jalur damage. Durasi = jumlah waktu fase 'fight' (jam ronde, tanpa intro dan K.O.); ronde = peralihan fight -> ko (ronde seri ikut dihitung).
- Akurasi jurus: satu pemakaian dihitung "mengenai" bila ada pukulan darinya yang kena (sekali per pemakaian). Pukulan dasar, lempar, dan gerakan perintah dikenali lewat hitSource() di game.js;
pukulan lain (proyektil, ultimate, burst) dikaitkan ke jurus yang terakhir dipakai dalam jendela WINDOW detik. Bila dua jurus bertumpuk, kaitannya bisa meleset satu: pendekatan yang disengaja
(proyektil tidak ditandai satu per satu).
- Simpanan seumur hidup: Meta.save.stats mendapat kunci baru (dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches) dan bestCombo yang sudah ada ikut dinaikkan.
Simpanan lama tanpa kunci itu terbaca sebagai 0. Hanya pertandingan yang selesai yang dihitung (keluar di tengah jalan tidak).
Semua fungsi publik aman: galat hanya dicatat di konsol dan tidak pernah menghentikan game. Tidak memakai Date.now/Math.random. */
(() => {
'use strict';
const KINDS = ['attack', 'skill1', 'skill2', 'ultimate']; // urutan sama dengan names[0..3] petarung
const WINDOW = { skill1: 3.2, skill2: 3.2, ultimate: 8 }; // detik waktu bertarung
const zero = () => ({ attack: 0, skill1: 0, skill2: 0, ultimate: 0 });
const started = new WeakSet(), landed = new WeakSet(); // aksi (hero.action) yang sudah dihitung dipakai / mengenai
let cur = null;
const fresh = () => ({ dealt: 0, taken: 0, combo: 0, counters: 0, parries: 0, blocks: 0, throws: 0, rounds: 0, time: 0, use: zero(), hit: zero(), last: null, prev: null, phase: '', done: false, snap: null });
const guard = (fn, fallback) => (...a) => { try { return fn(...a); } catch (e) { console.error('MatchStats:', e); return fallback; } };
// ---- pengumpulan (dipanggil game.js) ----
const reset = guard(active => { cur = active ? fresh() : null; });
const tick = guard((dt, m, hp, foe, foeMax) => {
const s = cur; if (!s || s.done || !m) return;
const f = foeMax > 0 ? Math.min(foe, foeMax) : foe;
if (s.prev) { if (hp < s.prev.hp) s.taken += s.prev.hp - hp; if (f > s.prev.foe) s.dealt += f - s.prev.foe; }
s.prev = { hp, foe: f };
if (m.phase === 'fight') s.time += dt; else if (m.phase === 'ko' && s.phase === 'fight') s.rounds++;
s.phase = m.phase;
});
// Dipanggil sesudah castMove/startAttack: aksi baru bertipe sama dengan jenisnya. Pemakaian yang ditahan (buffer lompat ganda) belum punya aksi, jadi baru terhitung saat benar-benar mulai.
const cast = guard((kind, act) => {
const s = cur; if (!s || s.done || !act || act.type !== kind || !(kind in s.use) || started.has(act)) return;
started.add(act); s.use[kind]++;
if (kind !== 'attack') s.last = { kind, t: s.time, hit: false };
});
// src: 'throw' | 'basic' | 'special' | null (jurus yang sudah lepas); counter: pukulan menimpa lawan yang sedang menyiapkan serangan; combo: hitungan kombo sesudah pukulan ini.
const hit = guard((src, counter, combo, act) => {
const s = cur; if (!s || s.done) return;
if (counter) s.counters++;
if (combo > s.combo) s.combo = combo | 0;
if (src === 'throw') { s.throws++; return; }
if (src === 'special') return;
if (src === 'basic') { if (act && !landed.has(act)) { landed.add(act); s.hit.attack++; } return; }
const c = s.last;
if (c && !c.hit && s.time - c.t <= WINDOW[c.kind]) { c.hit = true; s.hit[c.kind]++; }
});
const parry = guard(() => { if (cur && !cur.done) cur.parries++; });
const block = guard(() => { if (cur && !cur.done) cur.blocks++; });
// Ringkasan polos (aman disalin / disimpan): angka bulat, hit <= use per jenis.
function pack(s, info) {
const use = {}, got = {};
for (const k of KINDS) { use[k] = s.use[k] | 0; got[k] = Math.min(use[k], s.hit[k] | 0); }
return { v: 1, dealt: Math.round(s.dealt), taken: Math.round(s.taken), combo: Math.max(s.combo, +info?.matchBest || 0), counters: s.counters, parries: s.parries, blocks: s.blocks, throws: s.throws,
rounds: Math.max(s.rounds, (info?.playerWins | 0) + (info?.enemyWins | 0)), time: Math.round(s.time * 10) / 10, use, hit: got };
}
// Event 'complete' di game.js: menutup pengumpulan dan mengembalikan ringkasan (disalin tiap panggilan) atau null bila tidak ada pertandingan berstatistik.
const finish = guard(info => {
const s = cur; if (!s) return null;
if (!s.snap) { s.done = true; s.snap = pack(s, info); }
return JSON.parse(JSON.stringify(s.snap));
}, null);
const snapshot = guard(() => (cur ? { ...pack(cur), active: !cur.done } : null), null); // untuk uji manual di konsol: MatchStats.snapshot()
// Simpanan seumur hidup (meta.js award): menambah ke objek Meta.save.stats; kunci yang belum ada dianggap 0.
const commit = guard((st, s) => {
if (!st || !s || typeof st !== 'object') return;
const add = (k, n) => { st[k] = (+st[k] || 0) + (+n || 0); };
add('dmgDealt', s.dealt); add('dmgTaken', s.taken); add('parries', s.parries); add('blocks', s.blocks); add('throws', s.throws); add('counters', s.counters);
add('rounds', s.rounds); add('playSec', Math.round(s.time)); add('statMatches', 1);
st.bestCombo = Math.max(+st.bestCombo || 0, +s.combo || 0);
});
// ---- tampilan ----
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const num = n => String(Math.max(0, Math.round(+n || 0))).replace(/\B(?=(\d{3})+(?!\d))/g, '.'); // 12345 -> 12.345
const clock = sec => { const t = Math.max(0, Math.round(+sec || 0)); return Math.floor(t / 60) + ':' + String(t % 60).padStart(2, '0'); };
const span = sec => { const t = Math.max(0, Math.round(+sec || 0)), h = Math.floor(t / 3600), m = Math.floor(t % 3600 / 60); return h ? `${h} j ${m} mnt` : m ? `${m} mnt ${t % 60} dtk` : `${t} dtk`; };
const pct = (h, u) => (u > 0 ? Math.round(100 * h / u) : null);
// Layar hasil: {btn, panel}. btn = tombol di kepala layar hasil (kanan, sebaris skor); panel = lapisan di kolom tengah yang menutup bagian atas tombol aksi (mount mengaturnya).
const resultHtml = guard((s, ctx = {}) => {
if (!s || typeof s !== 'object') return { btn: '', panel: '' };
const names = window.FIGHTERS?.[ctx.id]?.names || window.Browo?.names || [];
const tile = (label, value, cls = '', sub = '', tip = '') => `<div${cls ? ` class="${cls}"` : ''}${tip ? ` title="${esc(tip)}"` : ''}><dt>${label}</dt><dd>${value}${sub ? `<small>${esc(sub)}</small>` : ''}</dd></div>`;
const acc = (label, k) => {
const u = Math.max(0, s.use?.[k] | 0), h = Math.min(u, Math.max(0, s.hit?.[k] | 0)), p = pct(h, u), tip = names[KINDS.indexOf(k)];
return `<div class="acc${p === null ? ' none' : ''}" style="--p:${p ?? 0}"${tip ? ` title="${esc(tip)}"` : ''}><dt>${label}</dt><dd>${p === null ? '—' : `${h}/${u}<small>${p}%</small>`}</dd></div>`;
};
const tiles = [tile('Damage keluar', num(s.dealt), 'dmg'), tile('Damage diterima', num(s.taken), 'hurt'), tile('Kombo terpanjang', num(s.combo), '', 'pukulan'), tile('Counter hit', num(s.counters)),
acc('Serangan dasar', 'attack'), acc('Skill 1', 'skill1'), acc('Skill 2', 'skill2'), acc('Ultimate', 'ultimate'),
tile('Parry berhasil', num(s.parries)), tile('Blok', num(s.blocks)), tile('Lempar berhasil', num(s.throws)), tile('Durasi', clock(s.time), '', `${num(s.rounds)} ronde`, 'Total waktu bertarung (jam ronde), tanpa intro dan layar K.O.')];
const who = `PEMAIN 1${ctx.name ? ' · ' + esc(String(ctx.name).toUpperCase()) : ''}${ctx.last ? ' · PERTANDINGAN TERAKHIR' : ''}`;
return {
btn: `<button type="button" class="rs-ms-btn" data-ms="open" aria-haspopup="dialog" aria-expanded="false" aria-controls="rs-ms"><span>STATISTIK<em> PERTANDINGAN</em></span><i aria-hidden="true">▸</i></button>`,
panel: `<div class="rs-ms" id="rs-ms" hidden><section class="rs-ms-card" role="dialog" aria-label="Statistik pertandingan"><header class="rs-ms-head"><div><small>${who}</small><h3>STATISTIK PERTANDINGAN</h3></div>`
+ `<button type="button" class="rs-ms-x" data-ms="close">TUTUP <b aria-hidden="true">✕</b></button></header><dl class="rs-ms-grid">${tiles.join('')}</dl></section></div>`
};
}, { btn: '', panel: '' });
// Setelah layar hasil dipasang (vs.js mount): tombol membuka/menutup lapisan, Esc dan klik di luar kartu menutupnya. Esc ditangkap lebih dulu supaya tidak ikut kembali ke menu utama.
const mount = guard(root => {
const rs = root?.querySelector?.('.rs'), btn = rs?.querySelector('[data-ms="open"]'), box = rs?.querySelector('.rs-ms');
if (!btn || !box) return;
const main = box.parentElement, closeBtn = box.querySelector('[data-ms="close"]');
// Lapisan berhenti tepat di atas tombol aksi (tinggi tombol berbeda tiap ukuran layar).
const place = () => { const a = main.querySelector('.rs-actions'); box.style.setProperty('--ms-b', (a ? Math.max(0, main.clientHeight - a.offsetTop) + 4 : 56) + 'px'); };
const onKey = e => { if (!box.isConnected) return off(); if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(); } };
const onSize = () => (box.isConnected ? place() : off());
const off = () => { window.removeEventListener('keydown', onKey, true); window.removeEventListener('resize', onSize); };
const open = () => { place(); box.hidden = false; btn.setAttribute('aria-expanded', 'true'); window.addEventListener('keydown', onKey, true); window.addEventListener('resize', onSize); closeBtn?.focus({ preventScroll: true }); };
const close = () => { box.hidden = true; btn.setAttribute('aria-expanded', 'false'); off(); btn.focus({ preventScroll: true }); };
btn.addEventListener('click', () => (box.hidden ? open() : close()));
box.addEventListener('click', e => { if (e.target === box || e.target.closest('[data-ms="close"]')) close(); });
});
// Tab Koleksi: ringkasan seumur hidup dari Meta.save.stats (kunci yang belum ada = 0).
const lifetimeHtml = guard(st => {
st = st && typeof st === 'object' ? st : {};
const n = k => Math.max(0, Math.round(+st[k] || 0)), tile = (label, value) => `<div><dt>${label}</dt><dd>${value}</dd></div>`;
return `<dl class="ms-life" aria-label="Statistik seumur hidup">${tile('Total damage', num(n('dmgDealt')))}${tile('Damage diterima', num(n('dmgTaken')))}${tile('Kombo terbaik', `${num(n('bestCombo'))}<small>pukulan</small>`)}`
+ `${tile('Parry berhasil', num(n('parries')))}${tile('Lempar berhasil', num(n('throws')))}${tile('Counter hit', num(n('counters')))}${tile('Blok', num(n('blocks')))}${tile('Waktu bertarung', span(n('playSec')))}</dl>`
+ `<p class="meta-hint">${n('statMatches') ? `Dihitung dari ${num(n('statMatches'))} pertandingan selesai sejak ada layar statistik` : 'Belum ada pertandingan yang tercatat'} (latihan tidak dihitung).</p>`;
}, '');
window.MatchStats = { reset, tick, cast, hit, parry, block, finish, snapshot, commit, resultHtml, mount, lifetimeHtml, WINDOW, fmt: { num, clock, span } };
})();

7
vs.js
View File

@ -122,6 +122,8 @@
const aw = r.award, lines = aw?.lines || [];
const coins = aw ? `<section class="rs-coins" aria-label="Rincian koin"><h3>KOIN</h3><ul>${lines.map((l, i) => `<li style="--i:${i}"><span>${esc(l.label)}${l.note ? `<small>${esc(l.note)}</small>` : ''}</span><b>+${l.amount}</b></li>`).join('')}</ul><div class="rs-total" style="--i:${lines.length}"><span>TOTAL</span><b data-count="${aw.gain}">◆ +${aw.gain}</b></div></section>` : '';
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).
const ms = window.MatchStats?.resultHtml(r.stats, { id: r.player, name: fighter(r.player).name, last: !!run }) || { btn: '', panel: '' };
const combo = r.matchBest ?? r.bestCombo ?? 0;
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}`)
@ -129,10 +131,10 @@
const html = `<div class="rs" data-tone="${tone}" style="--cw:${esc(Wf.color)};--cl:${esc(Lf.color)}">`
+ (tone === 'win' ? `<div class="rs-confetti" aria-hidden="true">${confetti(Wf.color, 26)}</div>` : '')
+ `<div class="rs-fighter win"><div class="rs-glow"></div><img class="rs-art" src="${esc(artOf(Wf))}" alt="${esc(Wf.name)}" draggable="false"><div class="rs-plate"><small>${boss === 'win' ? esc(window.Story?.title || 'PEMENANG') : 'PEMENANG'}</small><strong>${esc(Wf.name)}</strong></div></div>`
+ `<div class="rs-main"><div class="rs-head"><small class="rs-kicker">${esc(kicker)}</small><h1>${title}</h1>${scoreHtml}</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>`
+ `<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></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-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 };
}
@ -149,6 +151,7 @@
}
// 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 }); }
window.MatchStats?.mount(root);
}
window.ResultView = { view, mount };
})();