fighting-konoha/story.js

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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; } };
})();