148 lines
11 KiB
JavaScript
148 lines
11 KiB
JavaScript
/* Mode cerita Arcade (RENCANA 3.5) dan bos rahasia Bayangan (RENCANA 3.6): kartu cerita serta tampilan Bayangan di layar VS dan hasil.
|
|
Teks: content/story.js (window.STORY). Gaya: story.css (gaya Bayangan di HUD, VS, dan layar hasil juga ada di sana).
|
|
- Story.show(card, onDone): kartu layar penuh. card = {kind: 'intro' | 'between' | 'boss' | 'ending', player, enemy?, step?, total?, stage?, boss?}.
|
|
onDone dipanggil tepat satu kali: klik/ketuk/Spasi/Enter/Esc (setelah 0,5 dtk) atau otomatis (sekitar 4 dtk untuk kartu antar-lawan, lebih lama untuk kartu panjang).
|
|
Mengembalikan false, tanpa kartu dan tanpa onDone, bila tidak ada teks; pemanggil lalu melanjutkan sendiri.
|
|
- Story.decorateVs(info): game.js memanggilnya tepat setelah VS.show bila lawannya Bayangan (nama 'BAYANGAN <NAMA>', warna ungu, ucapan bos).
|
|
- Story.shadowFighter(f): data petarung vs.js menjadi versi Bayangan; Story.bossQuote(kind, id): ucapan Bayangan (kind: vs | win | lose); Story.title: gelar.
|
|
Selama kartu tampil semua tombol ditahan (tidak bocor ke menu atau game) dan Meta menahan toast (Story.active). Pengacak di sini kosmetik saja. */
|
|
(() => {
|
|
'use strict';
|
|
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
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 MIN_SKIP = 500, FADE = 260;
|
|
const NUM = ['nol', 'satu', 'dua', 'tiga', 'empat', 'lima', 'enam', 'tujuh', 'delapan', 'sembilan', 'sepuluh', 'sebelas', 'dua belas'];
|
|
const SHADOW = { name: 'BAYANGAN', tag: 'SALINAN GELAP', color: '#8b5cf6', title: 'PENAKLUK BAYANGAN' };
|
|
const CONFETTI = ['#ffe08a', '#ffffff', '#c9b3ff', '#8bd4c3'];
|
|
|
|
// ---- teks ----
|
|
const S = () => window.STORY || {};
|
|
const list = x => (Array.isArray(x) ? x : x == null ? [] : [x]).map(String).filter(t => t.trim());
|
|
const first = (...pools) => { for (const p of pools) { const l = list(p); if (l.length) return l; } return []; };
|
|
const oneOf = l => (l.length ? l[Math.floor(rnd() * l.length)] : '');
|
|
const fill = (t, v) => String(t).replace(/\{(\w+)\}/g, (m, k) => (v[k] == null ? m : v[k]));
|
|
const fighter = id => window.VS?.fighter?.(id) || { id, name: String(id || '?').toUpperCase(), tag: '', art: '', portrait: `assets/${id}/ui/portrait.webp`, color: '#e0bf85' };
|
|
const artOf = f => f.art || f.portrait;
|
|
const title = () => S().boss?.title || SHADOW.title;
|
|
function lines(card) {
|
|
const s = S(), g = s.generic || {}, b = s.boss || {}, id = card.player, P = fighter(id), E = card.enemy ? fighter(card.enemy) : null;
|
|
const v = { name: P.name, rival: E ? E.name : '', n: NUM[card.total] || String(card.total || '') };
|
|
let out = [];
|
|
if (card.kind === 'intro') out = first(s.intro?.[id], g.intro);
|
|
else if (card.kind === 'between') {
|
|
const t = typeof s.between === 'function' ? s.between(id, card.enemy, card.step, card.total) : s.between?.[id]?.[card.enemy];
|
|
out = first(t); if (!out.length) out = list(oneOf(list(g.between)));
|
|
} else if (card.kind === 'boss') out = list(b.lead).concat(first(b.intro?.[id], g.bossIntro));
|
|
else if (card.kind === 'ending') {
|
|
out = first(s.ending?.[id], g.ending);
|
|
if (card.boss === 'win') out = out.concat(first(b.end?.win?.[id], g.bossEndWin));
|
|
else if (card.boss === 'lose') out = out.concat(first(b.end?.lose?.[id], g.bossEndLose));
|
|
}
|
|
return out.map(t => fill(t, v));
|
|
}
|
|
function bossQuote(kind, id) {
|
|
const s = S(), key = kind === 'win' || kind === 'lose' ? kind : 'vs';
|
|
return fill(oneOf(first(s.boss?.[key]?.[id], s.generic?.['boss' + key[0].toUpperCase() + key.slice(1)])), { name: fighter(id).name });
|
|
}
|
|
const shadowFighter = f => ({ ...f, name: SHADOW.name + ' ' + f.name, tag: SHADOW.tag, color: SHADOW.color, shadow: true });
|
|
|
|
// ---- layar VS untuk Bayangan ----
|
|
// vs.js membangun layar seperti biasa (id sama dengan pemain); di sini sisi kanan diganti menjadi Bayangan (kelas .vs-shadow memberi warna ungu di story.css).
|
|
function decorateVs(info) {
|
|
const el = document.getElementById('vs-screen'), R = el?.querySelector('.vs-side.r');
|
|
if (!info || !R) return false;
|
|
const f = shadowFighter(fighter(info.enemy)), set = (sel, text) => { const n = R.querySelector(sel); if (n) n.textContent = text; };
|
|
el.classList.add('vs-shadow'); R.style.setProperty('--c', f.color);
|
|
set('.vs-who', 'BOS RAHASIA'); set('.vs-name', f.name); set('.vs-tag', f.tag); set('.vs-quote', '“' + bossQuote('vs', info.enemy) + '”');
|
|
el.setAttribute('aria-label', `${fighter(info.player).name} melawan ${f.name}. Klik atau tekan Spasi untuk melewati.`);
|
|
return true;
|
|
}
|
|
|
|
// ---- kartu cerita ----
|
|
const stageOf = id => window.MatchRules?.stages?.[id];
|
|
const DEFAULT_STAGE = { intro: 'bellora', ending: 'sunspire', boss: 'moonrise' };
|
|
function confetti(n) {
|
|
return Array.from({ length: n }, (_, i) => `<i style="--x:${(rnd() * 100).toFixed(1)}%;--d:${(rnd() * 1.6).toFixed(2)}s;--t:${(3 + rnd() * 2.4).toFixed(2)}s;--r:${Math.round(rnd() * 720 - 360)}deg;--w:${6 + Math.round(rnd() * 6)}px;--cc:${CONFETTI[i % CONFETTI.length]}"></i>`).join('');
|
|
}
|
|
function duration(kind, ls) {
|
|
const chars = ls.join('').length;
|
|
// Kartu antar-lawan dibaca sekitar 4 dtk; kartu panjang: kalimat muncul bergantian (0,95 dtk) lalu waktu baca.
|
|
return kind === 'between' ? clamp(3800 + Math.max(0, chars - 90) * 22, 3800, 6000) : clamp(ls.length * 950 + 3200 + chars * 14, 7000, 15000);
|
|
}
|
|
function markup(card, ls) {
|
|
const kind = card.kind, P = fighter(card.player), E = card.enemy ? fighter(card.enemy) : null, st = stageOf(card.stage || DEFAULT_STAGE[kind]);
|
|
const text = `<div class="st-text">${ls.map((t, i) => `<p class="st-line" style="--i:${i}">${esc(t)}</p>`).join('')}</div>`;
|
|
const top = (...parts) => `<div class="st-top">${parts.filter(Boolean).map(x => `<span>${esc(x)}</span>`).join('<i></i>')}</div>`;
|
|
const art = cls => `<figure class="st-fig ${cls}"><img class="st-art" src="${esc(artOf(P))}" alt="" draggable="false" decoding="async"></figure>`;
|
|
const ttl = S().title?.[kind]?.[card.player];
|
|
let head, figs = '', body, extra = '', color = P.color;
|
|
if (kind === 'between') {
|
|
const n = card.total || 0, k = card.step || 1;
|
|
const face = (cls, f) => `<figure class="st-face ${cls}" style="--c:${esc(f.color)}"><span class="fr"><img src="${esc(f.portrait)}" alt="" draggable="false" decoding="async"></span><figcaption>${esc(f.name)}</figcaption></figure>`;
|
|
head = top('ARCADE', n ? `LAWAN ${k} / ${n}` : '', st ? st.name.toUpperCase() : '');
|
|
body = `<small class="st-kicker">LAWAN BERIKUTNYA</small>${n ? `<div class="st-dots" aria-hidden="true">${Array.from({ length: n }, (_, i) => `<i class="${i < k - 1 ? 'done' : i === k - 1 ? 'now' : ''}"></i>`).join('')}</div>` : ''}<div class="st-vs">${face('l', P)}${text}${E ? face('r', E) : ''}</div>`;
|
|
} else if (kind === 'boss') {
|
|
color = SHADOW.color; head = top('ARCADE', 'BAB RAHASIA', st ? st.name.toUpperCase() : '');
|
|
figs = art('l') + art('r shadow');
|
|
body = `<small class="st-kicker">BOS RAHASIA</small><h2 class="st-title">${esc(ttl || SHADOW.name)}</h2>${text}`;
|
|
} else {
|
|
const ending = kind === 'ending';
|
|
head = top('ARCADE', ending ? 'EPILOG' : 'PROLOG', st ? st.name.toUpperCase() : '');
|
|
figs = art('l');
|
|
if (ending) extra = `<div class="st-confetti" aria-hidden="true">${confetti(24)}</div>`;
|
|
body = `<small class="st-kicker">${esc(P.name)}${P.tag ? ' · ' + esc(P.tag) : ''}</small><h2 class="st-title">${esc(ttl || (ending ? 'TAMAT' : 'PROLOG'))}</h2>${text}`
|
|
+ (ending && card.boss === 'win' ? `<div class="st-gelar" style="--i:${ls.length}"><small>${card.fresh ? 'GELAR BARU' : 'GELAR'}</small><b>${esc(title())}</b></div>` : '');
|
|
}
|
|
const bg = st?.image;
|
|
const hint = document.documentElement.classList.contains('touch') ? 'KETUK UNTUK LANJUT' : 'KLIK ATAU SPASI UNTUK LANJUT';
|
|
return { color, html: `<div class="st-bg"${bg ? ` style="background-image:url('${esc(bg)}')"` : ''}></div><i class="st-band"></i>${head}${figs}<div class="st-card"><div class="st-body">${body}</div></div>${extra}<div class="st-skip">${hint}</div><div class="st-timer" aria-hidden="true"><i></i></div>` };
|
|
}
|
|
|
|
let cur = null;
|
|
const SFX = { intro: 'confirm', between: 'move', boss: 'locked', ending: 'pick' };
|
|
function show(card = {}, onDone) {
|
|
cancel();
|
|
if (!document.body) return false;
|
|
const ls = lines(card); if (!ls.length) return false;
|
|
let el;
|
|
try {
|
|
const m = markup(card, ls);
|
|
el = document.createElement('div');
|
|
el.id = 'story-screen'; el.className = 'st-' + card.kind; el.setAttribute('role', 'dialog');
|
|
el.setAttribute('aria-label', card.kind === 'between' ? 'Cerita Arcade: lawan berikutnya. Klik atau tekan Spasi untuk lanjut.' : 'Cerita Arcade. Klik atau tekan Spasi untuk lanjut.');
|
|
el.style.setProperty('--c', m.color);
|
|
el.innerHTML = m.html;
|
|
document.body.append(el);
|
|
} catch (e) { console.error(e); el?.remove(); return false; }
|
|
const ms = duration(card.kind, ls), c = cur = { el, onDone, t0: performance.now(), done: false, timers: [], kind: card.kind };
|
|
el.style.setProperty('--st-run', (ms - FADE) + 'ms');
|
|
c.timers.push(setTimeout(() => finish('time'), ms - FADE));
|
|
el.addEventListener('pointerdown', e => { e.preventDefault(); skip(); });
|
|
window.addEventListener('keydown', onKey, true);
|
|
try { window.__game?.menuSound?.(SFX[card.kind] || 'move'); } catch (_) {}
|
|
return true;
|
|
}
|
|
// Selama kartu tampil semua tombol ditahan agar tidak bocor ke menu/game di bawahnya (Spasi/Enter tidak boleh memilih ulang karakter, Esc tidak membuka jeda).
|
|
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(), FADE + 40);
|
|
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'); }
|
|
// Membatalkan kartu tanpa onDone (mis. pertandingan dibatalkan); kartu yang sedang pudar tetap dibersihkan.
|
|
function cancel() { const c = release(); if (!c) return; c.done = true; c.el.remove(); cur = null; }
|
|
|
|
window.Story = { show, skip, cancel, lines, decorateVs, shadowFighter, bossQuote, get title() { return title(); }, get active() { return !!cur && !cur.done; }, force() { if (cur) { cur.t0 = -1e9; return skip(); } return false; } };
|
|
})();
|