158 lines
15 KiB
JavaScript
158 lines
15 KiB
JavaScript
/* 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.
|
||
- Dialog pasangan (RENCANA 3.1, content/dialogs.js): bila pasangan punya dialog, kutipan pembuka diganti dua balon ucapan (kiri = pemain, kanan = lawan)
|
||
dan layar tampil lebih lama (DURATION + DIALOG_EXTRA) supaya terbaca. Pasangan tersembunyi (DIALOGS.rahasia, RENCANA 3.4) menampilkan dialog rahasia
|
||
berlabel DIALOG RAHASIA dan memanggil Meta.pairEgg (koin dan toast sekali per pasangan, pencapaian REUNI).
|
||
Pengacak di sini kosmetik saja (bukan random() mesin), jadi tidak memengaruhi hasil pertandingan. */
|
||
(() => {
|
||
'use strict';
|
||
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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, DIALOG_EXTRA = 1100, 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]);
|
||
}
|
||
// Dialog pasangan: kunci berurutan alfabet, a = ucapan karakter pertama (pembuka), b = balasan. Hasil: {key, secret, l, r, reply} (l = sisi kiri/pemain, r = sisi kanan/lawan,
|
||
// reply = sisi yang membalas, muncul belakangan) atau null
|
||
// bila pasangan tidak punya dialog (mis. karakter yang sama) sehingga kutipan umum jadi cadangan. Dialog rahasia menggantikan dialog biasa.
|
||
const pairKey = (x, y) => (String(x) < String(y) ? x + '|' + y : y + '|' + x);
|
||
function dialog(idL, idR) {
|
||
if (!idL || !idR || idL === idR) return null;
|
||
const key = pairKey(idL, idR), all = window.DIALOGS, secret = all?.rahasia?.[key], d = secret || all?.[key];
|
||
if (!d || typeof d.a !== 'string' || typeof d.b !== 'string') return null;
|
||
const first = idL < idR;
|
||
return { key, secret: !!secret, l: first ? d.a : d.b, r: first ? d.b : d.a, reply: first ? 'r' : 'l' };
|
||
}
|
||
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;
|
||
// text = kutipan umum (diberi tanda kutip) atau, bila dlg ada, ucapan dialog pasangan dalam balon (ekor menunjuk ke papan nama).
|
||
function vsSide(cls, f, who, text, dlg) {
|
||
const talk = dlg ? `<p class="vs-quote vs-bubble${dlg.secret ? ' secret' : ''}${dlg.reply === cls ? ' re' : ''}">${esc(text)}</p>` : `<p class="vs-quote">“${esc(text)}”</p>`;
|
||
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>${talk}</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 dlg = dialog(L.id, R.id), dur = DURATION + (dlg ? DIALOG_EXTRA : 0);
|
||
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>`
|
||
+ (dlg?.secret ? `<div class="vs-secret"><i></i><span>DIALOG RAHASIA</span><i></i></div>` : '')
|
||
+ vsSide('l', L, 'PEMAIN 1', dlg ? dlg.l : quote(L.id, 'intro'), dlg) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', dlg ? dlg.r : quote(R.id, 'intro'), dlg)
|
||
+ `<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: [], dialog: dlg };
|
||
el.style.setProperty('--vs-run', (dur - FADE) + 'ms');
|
||
c.timers.push(setTimeout(() => finish('time'), dur - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));
|
||
// Dialog rahasia ditemukan: koin dan toast sekali per pasangan (toast ditahan Meta sampai layar VS selesai).
|
||
if (dlg?.secret) { try { window.Meta?.pairEgg?.(dlg.key); } catch (e) { console.error(e); } }
|
||
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, dialog, pairKey, fighter, duration: DURATION, durationDialog: DURATION + DIALOG_EXTRA, 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 = {}) {
|
||
// run.boss (RENCANA 3.6): 'win' = Bayangan dikalahkan, 'lose' = kalah dari Bayangan (Arcade tetap tamat, jadi pemain tetap tampil sebagai pemenang).
|
||
// Bayangan (salinan gelap, id sama dengan pemain) selalu tampil di sisi yang kalah dengan nama dan ucapan khasnya (story.js).
|
||
const run = r.run || null, two = !!r.p2, boss = run?.boss || null, playerWon = r.winner === 'player' || boss === 'lose';
|
||
const wid = playerWon ? r.player : r.enemy, lid = playerWon ? r.enemy : r.player, Wf = fighter(wid), Lf = boss && window.Story?.shadowFighter ? window.Story.shadowFighter(fighter(lid)) : fighter(lid);
|
||
const tone = run ? (run.cleared ? 'win' : 'lose') : (two || playerWon ? 'win' : 'lose');
|
||
const q = r._q || (r._q = { win: quote(wid, 'win'), lose: boss ? (window.Story?.bossQuote?.(boss, r.player) || quote(lid, '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 ? (boss === 'win' ? 'BAYANGAN DIKALAHKAN' : boss === 'lose' ? 'TAMAT · BAYANGAN BELUM TERKALAHKAN' : '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>`;
|
||
// 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}`)
|
||
: 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>${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}${ms.btn}</div>`
|
||
+ `<blockquote class="rs-quote"><p>“${esc(q.win)}”</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>${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>“${esc(q.lose)}”</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.MatchStats?.mount(root);
|
||
}
|
||
window.ResultView = { view, mount };
|
||
})();
|