302 lines
22 KiB
JavaScript
302 lines
22 KiB
JavaScript
/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)
|
||
};
|
||
})();
|