fighting-konoha/vs.js

133 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Layar VS (RENCANA 2.2), tampilan layar hasil (RENCANA 2.3) dan pemilih kutipan (RENCANA 2.6). Gaya: vs.css, data kutipan: content/quotes.js.
- window.VS.show(info, onDone): overlay sekitar 2,2 detik sebelum ronde 1. onDone dipanggil tepat satu kali (waktu habis atau dilewati
dengan klik/Spasi/Enter/Esc); setelah itu game memulai urutan cue ronde seperti biasa (game.js startMatch -> emitRoundCue).
- window.ResultView.view(hasil, ctx) -> {title, eyebrow, html}; ResultView.mount(root) menghidupkan hitungan koin dan memberi fokus ke wadah layar.
Pengacak di sini kosmetik saja (bukan random() mesin), jadi tidak memengaruhi hasil pertandingan. */
(() => {
'use strict';
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
let seed = (Date.now() ^ Math.floor(performance.now() * 1000)) >>> 0;
const rnd = () => { seed = (seed + 0x6D2B79F5) >>> 0; let t = seed; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const LEVELS = { easy: 'MUDAH', medium: 'SEDANG', hard: 'SULIT', excellent: 'SANGAT SULIT' };
const DURATION = 2200, FADE = 260, MIN_SKIP = 380;
// ---- kutipan ----
const GENERIC = { intro: ['Siap bertarung!', 'Ayo mulai.', 'Tidak ada yang mundur hari ini.'], win: ['Pertandingan yang bagus!', 'Terima kasih sudah bertanding.'], lose: ['Kali ini belum beruntung.', 'Aku akan kembali.'] };
const lastQuote = {};
function quote(id, kind) {
const own = window.QUOTES?.[id]?.[kind], pool = own && own.length ? own : GENERIC[kind] || GENERIC.intro, key = id + ':' + kind;
let i = Math.floor(rnd() * pool.length);
if (pool.length > 1 && pool[i] === lastQuote[key]) i = (i + 1 + Math.floor(rnd() * (pool.length - 1))) % pool.length;
return (lastQuote[key] = pool[i]);
}
function fighter(id) {
const f = window.FIGHTERS?.[id], portrait = `assets/${id}/ui/portrait.webp`;
if (f) return { id, name: f.name, tag: f.tag || '', art: f.select || `assets/menu/${id}-select.webp`, portrait, color: f.color || '#e0bf85' };
if (id === 'browo') return { id, name: 'BRO WO', tag: 'THE PALM RANGER', art: 'assets/menu/browo-select.webp', portrait, color: '#c9b87a' };
return { id, name: String(id || '?').toUpperCase(), tag: '', art: '', portrait: id === 'arco' ? 'assets/ui/arco-avatar.webp' : portrait, color: '#e0bf85' };
}
const artOf = f => f.art || f.portrait;
const stageName = id => window.MatchRules?.stages?.[id]?.name || '';
// ---- layar VS ----
let cur = null;
function vsSide(cls, f, who, q) {
return `<figure class="vs-side ${cls}" style="--c:${esc(f.color)}"><img class="vs-art" src="${esc(artOf(f))}" alt="" draggable="false"><figcaption class="vs-info"><small class="vs-who">${who}</small><strong class="vs-name">${esc(f.name)}</strong><em class="vs-tag">${esc(f.tag)}</em><p class="vs-quote">&ldquo;${esc(q)}&rdquo;</p></figcaption></figure>`;
}
function show(info = {}, onDone) {
cancel();
if (!document.body) return false;
const L = fighter(info.player), R = fighter(info.enemy), two = !!info.p2, st = stageName(info.stage), lv = LEVELS[info.level] || '';
const kicker = info.label || (two ? 'DUA PEMAIN' : 'VERSUS KOMPUTER');
const el = document.createElement('div');
el.id = 'vs-screen'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', `${L.name} melawan ${R.name}. Klik atau tekan Spasi untuk melewati.`);
const bg = window.MatchRules?.stages?.[info.stage]?.image;
el.innerHTML = `<div class="vs-bg"${bg ? ` style="background-image:url('${esc(bg)}')"` : ''}></div><i class="vs-panel l"></i><i class="vs-panel r"></i><i class="vs-slash"></i>`
+ `<div class="vs-top"><span>${esc(kicker)}</span>${st ? `<i></i><span>${esc(st.toUpperCase())}</span>` : ''}${lv && !two ? `<i></i><span>${lv}</span>` : ''}</div>`
+ vsSide('l', L, 'PEMAIN 1', quote(L.id, 'intro')) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', quote(R.id, 'intro'))
+ `<div class="vs-mark" aria-hidden="true"><b>VS</b></div><div class="vs-skip">KLIK ATAU SPASI UNTUK LEWATI</div><div class="vs-timer" aria-hidden="true"><i></i></div>`;
document.body.append(el);
window.Passives?.decorateVS(el, info);
const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [] };
el.style.setProperty('--vs-run', (DURATION - FADE) + 'ms');
c.timers.push(setTimeout(() => finish('time'), DURATION - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));
el.addEventListener('pointerdown', e => { e.preventDefault(); skip(); });
window.addEventListener('keydown', onKey, true);
return true;
}
// Selama layar VS aktif semua tombol ditahan agar tidak bocor ke game (Esc tidak boleh membuka jeda di bawahnya).
function onKey(e) {
if (!cur || cur.done) return;
e.stopImmediatePropagation(); e.preventDefault();
if (e.repeat) return;
if (e.key === ' ' || e.key === 'Enter' || e.key === 'Escape') skip();
}
function release() { const c = cur; if (!c) return null; window.removeEventListener('keydown', onKey, true); for (const t of c.timers) clearTimeout(t); c.timers = []; return c; }
function finish(reason) {
const c = cur; if (!c || c.done) return false;
c.done = true; release(); c.el.classList.add('out'); c.reason = reason;
setTimeout(() => c.el.remove(), reason === 'time' ? FADE + 40 : 200);
try { c.onDone?.(reason); } catch (e) { console.error(e); }
return true;
}
function skip() { if (!cur || cur.done || performance.now() - cur.t0 < MIN_SKIP) return false; return finish('skip'); }
// Dipanggil saat pertandingan dibatalkan (kembali ke menu / pertandingan baru): tanpa onDone.
function cancel() { const c = release(); if (!c) return; c.done = true; c.el.remove(); cur = null; }
window.VS = { show, skip, cancel, quote, fighter, duration: DURATION, get active() { return !!cur && !cur.done; }, seed(n) { seed = n >>> 0; }, force() { if (cur) { cur.t0 = -1e9; return skip(); } return false; } };
// ---- layar hasil ----
const CONFETTI = ['#ffe08a', '#ffffff', '#8bd4c3', '#ff9a7a'];
function confetti(color, n) {
return Array.from({ length: n }, (_, i) => `<i style="--x:${(rnd() * 100).toFixed(1)}%;--d:${(rnd() * 1.4).toFixed(2)}s;--t:${(2.6 + rnd() * 2.4).toFixed(2)}s;--r:${Math.round(rnd() * 720 - 360)}deg;--w:${6 + Math.round(rnd() * 6)}px;--c:${i % 3 === 0 ? color : CONFETTI[i % CONFETTI.length]}"></i>`).join('');
}
function view(r, ctx = {}) {
const run = r.run || null, two = !!r.p2, playerWon = r.winner === 'player';
const wid = playerWon ? r.player : r.enemy, lid = playerWon ? r.enemy : r.player, Wf = fighter(wid), Lf = fighter(lid);
const tone = run ? (run.cleared ? 'win' : 'lose') : (two || playerWon ? 'win' : 'lose');
const q = r._q || (r._q = { win: quote(wid, 'win'), lose: quote(lid, 'lose') });
const lv = LEVELS[r.level] || '', st = window.MatchRules?.stages?.[r.stage], stName = st ? st.name : '';
let title, kicker, scoreHtml;
if (run) {
title = run.cleared ? 'TAMAT!' : 'BERAKHIR';
kicker = run.kind === 'arcade' ? (run.cleared ? 'SEMUA LAWAN DIKALAHKAN' : `KALAH DI LAWAN KE-${run.wins + 1} DARI ${run.total}`) : `SURVIVAL · REKOR ${run.best}`;
scoreHtml = `<div class="rs-score run"><b>${run.wins}</b><span>${run.kind === 'arcade' ? `DARI ${run.total} LAWAN` : 'MENANG BERUNTUN'}</span></div>`;
} else {
title = two ? 'MENANG' : playerWon ? 'MENANG' : 'KALAH';
kicker = two ? (playerWon ? 'PEMAIN 1' : 'PEMAIN 2') + ' MENANG' : playerWon ? 'PEMAIN 1 MENANG' : 'KOMPUTER MENANG';
scoreHtml = `<div class="rs-score"><b class="${playerWon ? 'w' : ''}">${r.playerWins}</b><i>—</i><b class="${playerWon ? '' : 'w'}">${r.enemyWins}</b></div>`;
}
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>`;
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)) + stat(run.kind === 'arcade' ? 'Tingkat' : 'Rekor', run.kind === 'arcade' ? lv : String(run.best)) + stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde terakhir', `${r.playerWins} – ${r.enemyWins}`)
: stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde', `${r.playerWins} – ${r.enemyWins}`) + stat('Tingkat', two ? '—' : lv) + stat('Arena', st ? (st.short || stName) : '—');
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>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>`
+ `<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-fighter lose"><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 };
}
// Setelah html dipasang: hitung naik angka koin dan fokuskan wadah layar hasil (kecuali layar sentuh).
function mount(root) {
const rs = root.querySelector('.rs'); if (!rs) return;
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const n = rs.querySelector('[data-count]');
if (n && !calm) {
const to = Number(n.dataset.count) || 0, t0 = performance.now() + 700, dur = 900;
const tick = now => { if (!n.isConnected) return; const k = Math.min(1, Math.max(0, (now - t0) / dur)); n.textContent = '◆ +' + Math.round(to * (1 - Math.pow(1 - k, 3))); if (k < 1) requestAnimationFrame(tick); };
n.textContent = '◆ +0'; requestAnimationFrame(tick);
setTimeout(() => { if (n.isConnected) n.textContent = '◆ +' + to; }, 700 + dur + 400);
}
// 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.ResultView = { view, mount };
})();