132 lines
12 KiB
JavaScript
132 lines
12 KiB
JavaScript
/* 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 = '') => `<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 } };
|
|
})();
|