442 lines
29 KiB
JavaScript
442 lines
29 KiB
JavaScript
/* 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
|
|
};
|
|
})();
|