fighting-konoha/tutorial.js

302 lines
22 KiB
JavaScript
Raw Permalink 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.

/* Tutorial singkat (RENCANA 4.1). Tombol TUTORIAL di menu utama (pojok kiri atas, dipasang decorate() yang dipanggil menu.js di akhir render())
memulai latihan terpandu di arena latihan: karakter terakhir yang dimainkan pemain (atau Mega Chan), lawan = dummy diam yang hanya
menyerang atas perintah tutorial. Delapan langkah berurutan (gerak, lompat, kombo, gerakan perintah, skill, blok dan parry, lempar,
ultimate); tiap langkah punya satu atau dua sasaran yang dideteksi sendiri dari keadaan mesin dan dicentang, lalu pindah otomatis.
Panel instruksi (#tut-panel) ada di #game-shell, di bawah header HUD; gaya di tutorial.css. Tombol LEWATI melewati langkah, KELUAR kembali
ke menu utama. Selesai = +50 koin satu kali (Meta.addCoins); penandanya di Meta.save.eggs['code:tutorial'] (awalan "code:" supaya
tidak dihitung sebagai kejutan oleh slot misteri, lihat mystery.js).
Deteksi (tanpa mengubah aturan permainan): hero.state/jumps/action/blocking/cooldowns dan dummy.action dibaca langsung dari __game;
kombo dan damage lewat __game.stats(). Langkah blok/parry memicu serangan dummy lewat __game.dummyAttack(slow): pembukanya
diperlambat dan tanda "!" muncul di atas dummy 0,3 dtk sebelum kena; hasilnya (parry, blok, kena) dibaca dari pembungkus
__game.receiveHit (pola sama dengan pembungkus Bayangan di game.js; hanya mengamati, tidak mengubah hasil). Di layar sentuh teks dan tombol
yang disebut memakai tombol layar. Semua fungsi publik aman: bila ada galat, tutorial berhenti dan permainan jalan seperti biasa.
API: Tutorial.start({player?}), stop(), skip(), decorate(root, screen), done(), active, state(). Uji manual cepat: tombol TUTORIAL di menu
utama, lalu Tutorial.state() di konsol. */
(() => {
'use strict';
const FLAG = 'code:tutorial', COINS = 50;
const HOLD = 1.1; // jeda (detik) setelah langkah selesai sebelum pindah otomatis
const GAP = 1.7; // jeda antar serangan dummy di langkah blok dan parry
const REACH = 150; // serangan dummy hanya dilepas bila pemain sedekat ini (jangkauan serangannya sekitar 160 px)
const $ = s => document.querySelector(s);
const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const touch = () => document.documentElement.classList.contains('touch');
const game = () => window.__game;
const done = () => !!window.Meta?.save?.eggs?.[FLAG];
const skillName = slot => ($(`.skill-card[data-action="${slot}"] .skill-name`)?.textContent || '').trim();
const side = c => (c.dummy.x >= c.hero.x ? { name: 'kanan', kb: 'D', ts: '▶' } : { name: 'kiri', kb: 'A', ts: '◀' });
// c = konteks tiap frame { g, hero, dummy, st (kombo/damage), dt, fwd, ok(id), say(teks, nada, detik) }; t = catatan sementara per langkah.
// text: { kb, ts } atau fungsi (c, sentuh) -> teks. goals[].kb/ts: daftar tombol (atau fungsi c -> daftar; '+' = tanda tambah),
// label: teks atau fungsi, wait: fungsi c -> sisa cooldown (detik) yang ditampilkan di sasaran yang belum selesai.
// range: jarak mendatar ke dummy (px) di mana petunjuk "dekati dummy" muncul; face: petunjuk "hadapkan karakter" juga aktif.
const STEPS = [
{ id: 'move', title: 'GERAK',
text: { kb: 'Tahan A untuk ke kiri dan D untuk ke kanan. Ketuk dua kali untuk lari.', ts: 'Tahan panah ◀ untuk ke kiri dan ▶ untuk ke kanan. Ketuk dua kali untuk lari.' },
goals: [{ id: 'left', kb: ['A'], ts: ['◀'], label: 'Ke kiri' }, { id: 'right', kb: ['D'], ts: ['▶'], label: 'Ke kanan' }],
init: (c, t) => { t.x = c.hero.x; t.l = 0; t.r = 0; },
tick: (c, t) => {
const h = c.hero, dx = h.x - t.x; t.x = h.x;
if (h.grounded && (h.state === 'walk' || h.state === 'run')) { if (dx < 0) t.l -= dx; else t.r += dx; }
if (t.l >= 90) c.ok('left');
if (t.r >= 90) c.ok('right');
} },
{ id: 'jump', title: 'LOMPAT',
text: { kb: 'Tekan W untuk lompat. Tekan W lagi saat di udara untuk lompat ganda.', ts: 'Ketuk panah ▲ untuk lompat. Ketuk lagi saat di udara untuk lompat ganda.' },
goals: [{ id: 'jump', kb: ['W'], ts: ['▲'], label: 'Lompat' }, { id: 'double', kb: ['W', 'W'], ts: ['▲', '▲'], label: 'Lompat ganda' }],
tick: c => { const h = c.hero; if (h.jumps >= 1 && !h.grounded) c.ok('jump'); if (h.jumps >= 2) { c.ok('jump'); c.ok('double'); } } },
{ id: 'punch', title: 'PUKULAN DAN KOMBO', range: 175, face: true,
text: { kb: 'Dekati dummy, lalu tekan SPASI 3 kali berturut-turut untuk kombo 3 pukulan.', ts: 'Dekati dummy, lalu ketuk tombol SERANG (besar) 3 kali berturut-turut.' },
goals: [{ id: 'punch', kb: ['SPASI'], ts: ['SERANG'], label: 'Pukulan dasar' }, { id: 'combo', kb: ['SPASI ×3'], ts: ['SERANG ×3'], label: 'Kombo 3 pukulan' }],
init: (c, t) => { t.base = c.st.comboHits; },
tick: (c, t) => {
const a = c.hero.action, n = c.st.comboHits; if (n < t.base) t.base = 0; // kombo lama habis: hitung dari nol
if (a?.type === 'attack' && !a.special) c.ok('punch');
if (n - t.base >= 1) c.ok('punch');
if (n - t.base >= 3) { c.ok('punch'); c.ok('combo'); }
} },
{ id: 'command', title: 'GERAKAN PERINTAH', range: 230,
text: { kb: '↓↓+SPASI = uppercut, ↓→+SPASI = tinju maju. ↓ = tombol S, → = arah hadap.', ts: 'Ketuk UP untuk uppercut dan →P untuk tinju maju (tombol kecil di kanan).' },
goals: [
{ id: 'uppercut', kb: ['S', 'S', '+', 'SPASI'], ts: ['UP'], label: 'Uppercut', wait: c => c.hero.spCd },
{ id: 'lunge', kb: c => ['S', c.fwd, '+', 'SPASI'], ts: ['→P'], label: 'Tinju maju', wait: c => c.hero.spCd }],
tick: c => { const s = c.hero.action?.special; if (s === 'uppercut') c.ok('uppercut'); if (s === 'lunge') c.ok('lunge'); } },
{ id: 'skill', title: 'SKILL',
text: { kb: 'Tekan I untuk skill 1 dan O untuk skill 2. Cooldown ada di kartu skill.', ts: 'Ketuk tombol skill 1 dan skill 2 (bulat kecil di kiri-atas tombol serang).' },
goals: [
{ id: 's1', kb: ['I'], ts: ['SKILL 1'], label: () => skillName('skill1') || 'Skill 1', wait: c => c.hero.cooldowns?.skill1 },
{ id: 's2', kb: ['O'], ts: ['SKILL 2'], label: () => skillName('skill2') || 'Skill 2', wait: c => c.hero.cooldowns?.skill2 }],
tick: c => { const k = c.hero.action?.type; if (k === 'skill1') c.ok('s1'); if (k === 'skill2') c.ok('s2'); } },
{ id: 'guard', title: 'BLOK DAN PARRY', range: REACH,
text: (c, ts) => S.goals.block
? (ts ? 'Sekarang KETUK BLOK sekali, tepat sebelum pukulan kena (saat tanda ! muncul).' : 'Sekarang KETUK L sekali, tepat sebelum pukulan kena (saat tanda ! muncul).')
: (ts ? 'Dummy akan menyerang. TAHAN tombol BLOK sampai pukulannya kena.' : 'Dummy akan menyerang. TAHAN L untuk memblok sampai pukulannya kena.'),
goals: [{ id: 'block', kb: ['L (tahan)'], ts: ['BLOK (tahan)'], label: 'Blok' }, { id: 'parry', kb: ['L (ketuk)'], ts: ['BLOK (ketuk)'], label: 'Parry' }],
init: (c, t) => { t.hold = 0; t.next = S.clock + GAP; t.acting = false; },
tick: (c, t) => {
const h = c.hero, d = c.dummy, first = !S.goals.block;
t.hold = h.blocking ? t.hold + c.dt : 0; if (t.hold >= 2.5) c.ok('block'); // cadangan: menahan L lama; yang utama blok sungguhan (hasil 'block')
const hit = S.hit; S.hit = null; // hasil serangan dummy terakhir, dicatat pembungkus receiveHit
if (hit === 'parry') { c.ok('parry'); c.say('PARRY! Tepat waktu.', 'good', 2); }
else if (hit === 'block') { if (first) { c.ok('block'); c.say('Blok berhasil! Sekarang coba parry.', 'good', 2.4); } else c.say('Itu blok biasa. Untuk parry: lepas L, lalu ketuk sekali tepat sebelum kena.', 'hint', 3); }
else if (hit === 'hit') c.say('Kena! Tahan L untuk memblok, atau ketuk L tepat sebelum kena.', 'bad', 2.6);
if (typeof c.g.dummyAttack !== 'function' && !t.warned) { t.warned = true; c.say('Serangan dummy tidak tersedia di build ini. Tekan LEWATI.', 'bad', 8); }
const a = d.action, lead = .14 + .16 * (c.g.passiveMod ? c.g.passiveMod(h, 'parry', 1) : 1); // tanda muncul sedikit lebih awal dari jendela parry (0,16 dtk x pengali pasif)
if (a) t.acting = true; else if (t.acting) { t.acting = false; t.next = S.clock + GAP; }
cueAt(a && !a.fired && a.type === 'attack' && S.goals.block && !S.goals.parry && a.duration * a.hitAt - a.t <= lead ? d : null);
// Blok: pembuka agak lambat; parry: lebih lambat lagi supaya bisa dibaca (jendela parry hanya 0,16 dtk).
if (!a && S.clock >= t.next && typeof c.g.dummyAttack === 'function' && Math.abs(h.x - d.x) <= REACH && h.grounded && !h.action && h.hurtTime <= 0 && h.invuln <= 0 && c.g.dummyAttack(S.goals.block ? 4 : 3)) t.acting = true;
} },
{ id: 'throw', title: 'LEMPAR', range: REACH,
text: { kb: 'Berdiri mepet di depan dummy (di tanah) lalu tekan J. Lemparan menembus blok.', ts: 'Berdiri mepet di depan dummy lalu ketuk LEMPAR. Lemparan menembus blok.' },
goals: [{ id: 'throw', kb: ['J'], ts: ['LEMPAR'], label: 'Lempar dummy', wait: c => c.hero.throwCd }],
init: (c, t) => { t.tot = c.st.totalDamage; t.seen = false; t.hit = false; },
tick: (c, t) => {
const a = c.hero.action, tot = c.st.totalDamage;
if (a?.special === 'throw') { t.seen = true; if (tot > t.tot) { t.hit = true; c.ok('throw'); } }
else if (t.seen) { if (!t.hit) c.say('Meleset. Berdiri mepet di depan dummy, di tanah, lalu tekan J lagi.', 'bad', 3); t.seen = t.hit = false; }
t.tot = tot;
} },
{ id: 'ultimate', title: 'ULTIMATE',
text: { kb: 'Tekan P untuk ultimate, serangan pamungkas dengan cooldown panjang.', ts: 'Ketuk tombol ultimate (bulat, paling atas di busur tombol serang).' },
goals: [{ id: 'ult', kb: ['P'], ts: ['ULTIMATE'], label: () => skillName('ultimate') || 'Ultimate', wait: c => c.hero.cooldowns?.ultimate }],
init: (c, t) => { t.cd = c.hero.cooldowns?.ultimate || 0; },
tick: (c, t) => { const cd = c.hero.cooldowns?.ultimate || 0; if (c.hero.action?.type === 'ultimate' || (t.cd <= 0 && cd > 0)) c.ok('ult'); t.cd = cd; } }
];
let S = null, raf = 0, lastTs = 0, hooked = false, panel = null, cue = null, ui = null;
// ---- menu utama: tombol kecil di pojok kiri atas ----
function decorate(root, screen) {
try {
if (!root || screen !== 'home') return;
const slot = root.querySelector('.home-top'); if (!slot || slot.querySelector('.tut-entry')) return;
const ok = done(), b = document.createElement('button');
b.type = 'button'; b.className = 'tut-entry' + (ok ? ' done' : ''); b.dataset.tut = 'start';
b.setAttribute('aria-label', ok ? 'Tutorial, sudah selesai. Ulangi tutorial terpandu' : 'Mulai tutorial terpandu di arena latihan');
b.innerHTML = `<i aria-hidden="true">${ok ? '✓' : '▶'}</i><span>TUTORIAL</span>`;
b.onclick = () => { game()?.menuSound?.('confirm'); start(); };
slot.append(b);
} catch (e) { console.error(e); }
}
// ---- mulai dan berhenti ----
function pick(opt) {
const roster = Object.keys(window.FIGHTERS || {}); if (window.Browo && window.BROWO_MANIFEST) roster.unshift('browo');
let me = opt?.player; if (!roster.includes(me)) { try { me = game().snapshot().selectedCharacter; } catch (_) { me = null; } }
if (!roster.includes(me)) me = roster.includes('megachan') ? 'megachan' : roster[0];
// Lawan harus petarung berbasis data (window.FIGHTERS): serangan dasarnya dipakai dummyAttack.
const foe = ['soloman', 'megachan', 'gufron'].find(id => id !== me && window.FIGHTERS?.[id]) || roster.find(id => id !== me && window.FIGHTERS?.[id]) || me;
return { player: me, enemy: foe };
}
function start(opt) {
try {
const G = game(); if (!G) return false;
stop();
if (typeof G.stats !== 'function' || typeof G.dummyAttack !== 'function') console.warn('Tutorial: kait __game.stats / dummyAttack tidak ada di game.js; deteksi kombo dan latihan parry tidak jalan.');
const f = pick(opt);
window.Meta?.cancelRun?.();
if (!G.startMatch({ player: f.player, enemy: f.enemy, stage: 'bellora', level: 'easy', mode: 'training' })) { window.Meta?.toast?.('Aset belum siap. Tunggu sebentar lalu coba lagi.'); return false; }
hook();
S = { i: 0, phase: 'run', goals: {}, t: {}, skipped: [], seen: {}, say: null, hit: null, fresh: false, clock: 0, doneAt: 0, reward: 0, c: null, key: '' };
build(); initStep();
document.body.classList.add('tut-on'); addEventListener('resize', fit);
$('#arena')?.focus({ preventScroll: true });
lastTs = 0; raf = requestAnimationFrame(loop);
return true;
} catch (e) { console.error(e); stop(); return false; }
}
function stop() {
if (raf) cancelAnimationFrame(raf); raf = 0; S = null;
removeEventListener('resize', fit);
panel?.remove(); cue?.remove(); panel = cue = ui = null;
document.body.classList.remove('tut-on'); document.body.style.removeProperty('--tut-bottom');
}
function exit() { stop(); window.FrontEnd?.home?.(); }
function skip() {
if (!S || S.phase === 'finish') return;
if (S.phase === 'run') S.skipped.push(S.i);
next();
}
// Mengamati hasil serangan dummy ke pemain (parry, blok, kena). Hanya membaca keadaan sebelum dan sesudah; hasil asli tidak diubah.
function hook() {
const G = game(); if (hooked || typeof G?.receiveHit !== 'function') return; hooked = true;
const base = G.receiveHit;
G.receiveHit = function (damage, options) {
const h = G.hero, pre = { parry: h.parry > 0 && !h.action, block: !!h.blocking && !h.action };
const ok = base.apply(this, arguments);
try { if (ok && S && S.phase === 'run' && STEPS[S.i].id === 'guard') S.hit = pre.parry && !(h.parry > 0) ? 'parry' : pre.block && h.hurtTime <= 0 ? 'block' : h.hurtTime > 0 ? 'hit' : null; } catch (_) { /* hanya pengamat */ }
return ok;
};
}
// ---- alur langkah ----
function ctx(dt) {
const G = game(), hero = G.hero, dummy = G.dummy;
return {
g: G, hero, dummy, dt, fwd: hero.facing > 0 ? 'D' : 'A',
st: typeof G.stats === 'function' ? G.stats() : { comboHits: 0, bestCombo: 0, totalDamage: 0 },
ok: id => { if (!S.goals[id]) { S.goals[id] = true; S.fresh = true; } },
say: (text, tone = 'hint', secs = 2.4) => { S.say = { text, tone, until: S.clock + secs }; }
};
}
function initStep() {
S.goals = {}; S.t = {}; S.say = null; S.hit = null; S.fresh = false; S.phase = 'run'; cueAt(null);
STEPS[S.i].init?.(ctx(0), S.t);
sr(`Langkah ${S.i + 1} dari ${STEPS.length}: ${STEPS[S.i].title}`);
paint(true);
}
function next() {
cueAt(null);
if (++S.i >= STEPS.length) { finish(); return; }
initStep();
}
function finish() {
S.phase = 'finish'; S.i = STEPS.length - 1; S.reward = reward() ? COINS : 0;
game()?.menuSound?.('pick'); sr('Tutorial selesai'); paint(true);
}
function reward() {
try {
const M = window.Meta, save = M?.save; if (!M || !save) return false;
if (!save.eggs || typeof save.eggs !== 'object') save.eggs = {};
if (save.eggs[FLAG]) return false;
save.eggs[FLAG] = 1; M.addCoins(COINS); M.toast?.(`TUTORIAL SELESAI +${COINS} koin`, 'ach');
return true;
} catch (e) { console.error(e); return false; }
}
// Menjaga latihan tetap nyaman: HP pemain dan dummy tidak habis oleh percobaan berulang.
function keep(c) {
const h = c.hero, d = c.dummy;
if (h.hp > 0 && h.hp < 90) h.hp = c.g.balance?.heroMax || 200;
if (!d.ko && d.damage >= 100 && d.state === 'idle') d.damage = 0;
}
function loop(ts) {
raf = requestAnimationFrame(loop);
if (!S) return;
const dt = lastTs ? Math.min(.1, (ts - lastTs) / 1000) : 0; lastTs = ts;
try { update(dt); } catch (e) { console.error(e); stop(); }
}
function update(dt) {
if (document.body.classList.contains('in-menu') || !game()) { stop(); return; }
if (document.hidden || $('#pause-panel')?.open || $('#settings-panel')?.open) return; // jeda: waktu tutorial ikut berhenti
S.clock += dt;
const c = ctx(dt); S.c = c; keep(c);
if (S.phase === 'run') {
const st = STEPS[S.i]; st.tick?.(c, S.t);
if (st.goals.every(x => S.goals[x.id])) { S.phase = 'done'; S.doneAt = S.clock; cueAt(null); c.g.menuSound?.('confirm'); sr(`Langkah ${S.i + 1} selesai`); }
else if (S.fresh) c.g.menuSound?.('blink');
} else if (S.phase === 'done' && S.clock - S.doneAt >= HOLD) next();
S.fresh = false;
paint(false);
}
// ---- panel ----
function build() {
const host = $('#game-shell') || document.body;
panel = document.createElement('section'); panel.id = 'tut-panel'; panel.setAttribute('role', 'region'); panel.setAttribute('aria-label', 'Tutorial terpandu');
panel.innerHTML = '<div class="tut-bar"><i></i></div><header class="tut-head"><span class="tut-step"></span><h2 class="tut-title"></h2><span class="tut-actions"><button type="button" class="tut-btn tut-skip">LEWATI ▸</button><button type="button" class="tut-btn tut-free" hidden>LATIHAN BEBAS</button><button type="button" class="tut-btn tut-exit">KELUAR ✕</button></span></header><p class="tut-text"></p><ul class="tut-goals"></ul><span class="tut-sr" role="status" aria-live="polite"></span>';
ui = { bar: panel.querySelector('.tut-bar i'), step: panel.querySelector('.tut-step'), title: panel.querySelector('.tut-title'), text: panel.querySelector('.tut-text'), goals: panel.querySelector('.tut-goals'), skip: panel.querySelector('.tut-skip'), free: panel.querySelector('.tut-free'), exit: panel.querySelector('.tut-exit'), sr: panel.querySelector('.tut-sr') };
// Fokus tetap di kanvas: tombol yang sedang fokus akan menelan SPASI (serangan) sebagai klik.
const back = () => $('#arena')?.focus({ preventScroll: true });
for (const [b, fn] of [[ui.skip, () => { skip(); back(); }], [ui.free, () => { stop(); back(); }], [ui.exit, exit]]) { b.onmousedown = e => e.preventDefault(); b.onclick = fn; }
cue = document.createElement('div'); cue.className = 'tut-cue'; cue.setAttribute('aria-hidden', 'true'); cue.innerHTML = '<b>!</b><small>L</small>';
host.append(panel, cue);
}
function sr(text) { if (ui) { ui.sr.textContent = ''; ui.sr.textContent = text; } }
// Tanda "!" di atas kepala dummy: koordinat dunia -> layar dengan rumus yang sama dengan resize() di game.js.
function cueAt(actor) {
if (!cue) return;
if (!actor) { cue.classList.remove('on'); return; }
const vw = innerWidth, vh = innerHeight, s = Math.min(vw / 1280, vh / 720), ox = (vw - 1280 * s) / 2, oy = vh * .815 - game().config.groundY * s;
cue.style.left = Math.round(ox + actor.x * s) + 'px'; cue.style.top = Math.round(oy + (actor.y - 185) * s) + 'px';
cue.style.setProperty('--cs', String(Math.max(.7, Math.min(1.25, s * 1.15)))); cue.classList.add('on');
}
function fit() { if (panel) document.body.style.setProperty('--tut-bottom', Math.round(panel.getBoundingClientRect().bottom) + 'px'); }
// Teks yang tampil: pesan sementara > petunjuk jarak/arah > teks langkah.
function message(c, st, base) {
if (S.say && S.clock < S.say.until) return S.say;
if (S.phase === 'done') return { text: S.i === STEPS.length - 1 ? 'Bagus! Tutorial hampir selesai…' : 'Bagus! Lanjut ke langkah berikutnya…', tone: 'good' };
if (st.range) {
const gap = c.dummy.x - c.hero.x, d = side(c), key = touch() ? d.ts : d.kb;
if (Math.abs(gap) > st.range) return { text: `Jauh dari dummy. Jalan ke ${d.name} (${key}).`, tone: 'hint' };
if (st.face && gap * c.hero.facing < -8) return { text: `Hadapkan karaktermu ke dummy: tekan ${key}.`, tone: 'hint' };
}
return { text: base, tone: '' };
}
function paint(force) {
if (!S || !ui) return;
const c = S.c || ctx(0), ts = touch(), fin = S.phase === 'finish', st = STEPS[S.i];
let v;
if (fin) {
const n = S.skipped.length, tip = ' Tekan Esc kapan saja untuk daftar gerakan.';
v = { step: 'SELESAI', title: 'TUTORIAL SELESAI', tone: 'good', pct: 100, goals: [],
text: (S.reward ? `Semua dasar sudah kamu coba. Hadiah +${COINS} koin masuk ke dompetmu.` : 'Semua dasar sudah kamu coba. Hadiah koin tutorial sudah pernah kamu ambil.') + (n ? ` ${n} langkah dilewati; ulangi dari menu kapan saja.` : tip) };
} else {
const base = typeof st.text === 'function' ? st.text(c, ts) : (ts ? st.text.ts : st.text.kb), m = message(c, st, base);
v = { step: `LANGKAH ${S.i + 1}/${STEPS.length}`, title: st.title, tone: m.tone, text: m.text, pct: (S.i + (S.phase === 'done' ? 1 : 0)) / STEPS.length * 100,
goals: st.goals.map(x => {
const k = ts ? x.ts : x.kb, w = typeof x.wait === 'function' ? Math.ceil(x.wait(c) || 0) : 0, ok = !!S.goals[x.id];
return { id: S.i + x.id, keys: typeof k === 'function' ? k(c) : k, label: typeof x.label === 'function' ? x.label(c) : x.label, ok, wait: ok ? 0 : w };
}) };
}
const key = JSON.stringify([v.step, v.title, v.tone, v.text, v.goals]);
if (!force && key === S.key) return;
S.key = key;
ui.step.textContent = v.step; ui.title.textContent = v.title; ui.text.textContent = v.text; ui.text.className = 'tut-text' + (v.tone ? ' ' + v.tone : '');
ui.bar.style.width = v.pct + '%'; panel.classList.toggle('fin', fin);
ui.skip.hidden = fin; ui.free.hidden = !fin; ui.exit.textContent = fin ? 'KE MENU' : 'KELUAR ✕';
ui.goals.hidden = fin;
// Animasi centang hanya untuk sasaran yang baru selesai (S.seen), bukan tiap panel digambar ulang.
ui.goals.innerHTML = v.goals.map(x => { const fresh = x.ok && !S.seen[x.id]; if (x.ok) S.seen[x.id] = 1; return `<li class="tut-goal${x.ok ? ' done' : ''}${fresh ? ' new' : ''}"><i class="box" aria-hidden="true">✓</i><span class="keys">${x.keys.map(k => (k === '+' ? '<b class="plus">+</b>' : `<kbd class="tut-key">${esc(k)}</kbd>`)).join('')}</span><span class="lab">${esc(x.label)}${x.wait ? ` <small>tunggu ${x.wait} dtk</small>` : ''}</span>${x.ok ? '<span class="tut-sr"> selesai</span>' : ''}</li>`; }).join('');
fit();
}
window.Tutorial = {
start, stop, skip, decorate, done,
steps: STEPS.map(s => s.id),
get active() { return !!S; },
state: () => (S ? { step: S.i, id: STEPS[S.i]?.id, phase: S.phase, goals: { ...S.goals }, skipped: [...S.skipped], clock: +S.clock.toFixed(2), reward: S.reward } : null)
};
})();