/* 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 ', 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) => ``).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 = `
${ls.map((t, i) => `

${esc(t)}

`).join('')}
`; const top = (...parts) => `
${parts.filter(Boolean).map(x => `${esc(x)}`).join('')}
`; const art = cls => `
`; 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) => `
${esc(f.name)}
`; head = top('ARCADE', n ? `LAWAN ${k} / ${n}` : '', st ? st.name.toUpperCase() : ''); body = `LAWAN BERIKUTNYA${n ? `` : ''}
${face('l', P)}${text}${E ? face('r', E) : ''}
`; } else if (kind === 'boss') { color = SHADOW.color; head = top('ARCADE', 'BAB RAHASIA', st ? st.name.toUpperCase() : ''); figs = art('l') + art('r shadow'); body = `BOS RAHASIA

${esc(ttl || SHADOW.name)}

${text}`; } else { const ending = kind === 'ending'; head = top('ARCADE', ending ? 'EPILOG' : 'PROLOG', st ? st.name.toUpperCase() : ''); figs = art('l'); if (ending) extra = ``; body = `${esc(P.name)}${P.tag ? ' · ' + esc(P.tag) : ''}

${esc(ttl || (ending ? 'TAMAT' : 'PROLOG'))}

${text}` + (ending && card.boss === 'win' ? `
${card.fresh ? 'GELAR BARU' : 'GELAR'}${esc(title())}
` : ''); } const bg = st?.image; const hint = document.documentElement.classList.contains('touch') ? 'KETUK UNTUK LANJUT' : 'KLIK ATAU SPASI UNTUK LANJUT'; return { color, html: `
${head}${figs}
${body}
${extra}
${hint}
` }; } 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; } }; })();