/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = '') => `