/* 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 = `${ok ? '✓' : '▶'}TUTORIAL`; 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 = '