/* Latihan lanjutan (RENCANA 6.5, training.js + training.css): panel "DUMMY" di arena latihan (bukan di tutorial). Tombol DUMMY di bilah kecil bawah jam (di samping Pengaturan dan Jeda) membuka panel kecil yang bisa ditutup, dipindah ke sisi lain, dan ingatannya (buka/tutup, sisi; bawaan kiri supaya penghitung kombo besar di kanan tidak tertutup) tersimpan di localStorage['konoha.training.v1']. Isi panel: Perilaku dummy: Diam (bawaan), Blok terus, Lompat terus, Serang acak, Ikuti pemain, CPU penuh (= saklar "Lawan aktif" di Pengaturan, tetap sinkron), dan Reset posisi. Alat bantu: Tampilkan hitbox (setHitboxes yang sudah ada), Tampilkan frame data (startup, aktif, recovery, total dalam ms, jangkauan dalam px, damage dasar, dari data jurus serangan terakhir pemain), Darah dummy tak terbatas (dummy tidak pernah K.O.; bar terisi lagi 1 dtk setelah kombo berhenti), dan penghitung kombo/damage. Rekam dan putar ulang: Rekam menyimpan tombol yang kamu tekan dan dilepas beserta waktu simulasinya, paling lama 10 dtk, mulai pada tombol pertama (arena direset lebih dulu dan dummy menunggu); Putar mereset arena yang sama lalu menekan ulang urutan itu lewat __game.press/release/input terjadwal oleh waktu simulasi. Benih random() mesin disetel sama saat rekam dan putar dimulai supaya dummy "Serang acak" bertindak sama. Hasil putar dibandingkan dengan hasil rekaman (damage dan kombo terbaik) dan ditulis SAMA atau BERBEDA; tidak dijamin sama persis (mis. tombol yang ditekan lewat klik kartu skill di HUD tidak terekam). Kait di game.js (semuanya `window.Training?.` satu baris): init (startMatch), pre (awal tiap langkah simulasi: jadwal putar, darah tak terbatas, aksi terakhir), drive (cabang dummy tanpa AI di updateFenrAI), blocks (hitDummy), rec (press, release, forceCommand, clearInput), reset (resetCombat). Akses sempit ke mesin lewat __game.dummyCtl, __game.kitMove, __game.rng. Semua tidak aktif di versus, saat Tutorial.active, dan di menu. Bila ada galat, fitur berhenti sendiri dan game berjalan seperti biasa. */ (() => { 'use strict'; const MAXREC = 10; // detik rekaman paling lama const SEED = 20261005; // benih random() mesin saat rekam dan putar dimulai const KEY = 'konoha.training.v1'; const $ = s => document.querySelector(s); const G = () => window.__game; const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const num = (n, d = 1) => Number(n).toFixed(d).replace('.', ','); const ms = s => Math.round(s * 1000) + ' ms'; const BEH = [['diam', 'Diam'], ['blok', 'Blok terus'], ['lompat', 'Lompat terus'], ['serang', 'Serang acak'], ['ikuti', 'Ikuti pemain'], ['cpu', 'CPU penuh']]; // Gerakan khusus pemain: nama, tombol, dan jangkauan (disalin dari specialHit di game.js; hanya untuk tampilan). const SPECIAL = { uppercut: { name: 'UPPERCUT', sub: 'bawah, bawah + SPASI', range: '150 px di depan, tinggi ±150 px' }, lunge: { name: 'TINJU MAJU', sub: 'bawah, maju + SPASI', range: '175 px di depan, sambil meluncur' }, throw: { name: 'LEMPAR', sub: 'tombol J', range: '125 px di sekitar, menembus blok' }, seruduk: { name: 'SERUDUK KILAT', sub: 'jurus toko (K)', range: '190 px di depan, sambil meluncur' }, hentak: { name: 'HENTAKAN BUMI', sub: 'jurus toko (K)', range: '230 px di sekitar' } }; const S = { on: false, fail: false, beh: 'diam', inf: false, fd: false, open: false, side: 'left', next: 0, dj: false, time: 0, last: null, fdv: null, fdn: 0, cmb: { live: false, hits: 0, dmg: 0, last: null }, busy: false, ui: null, key: '' }; const R = { state: 'idle', ev: [], down: new Set(), t0: 0, p0: null, i: 0, dur: 0, beh: 'diam', inf: false, ref: null, res: null, msg: '', tone: '', keep: null }; try { const v = JSON.parse(localStorage.getItem(KEY) || 'null'); if (v) { S.open = !!v.open; S.side = v.side === 'right' ? 'right' : 'left'; } } catch (_) { /* tanpa simpanan */ } const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ open: S.open, side: S.side })); } catch (_) { /* abaikan */ } }; const usable = () => S.on && !S.fail && !window.Tutorial?.active && $('#arena')?.dataset.mode === 'training' && !document.body.classList.contains('in-menu'); const guard = fn => (...a) => { try { return fn(...a); } catch (e) { console.error(e); S.fail = true; return false; } }; // ---- perilaku dummy: dipanggil updateFenrAI hanya bila AI mati (Diam = tidak ada yang dilakukan) ---- function loco(d, dt) { const v = Math.abs(d.vx), next = v > 5 ? (v > 300 ? 'run' : 'walk') : 'idle'; if (next !== d.state) { d.state = next; d.stateTime = 0; } d.walkPhase = (d.walkPhase || 0) + v * dt / 150; } function driveAttack(g, d, h, c, t, dt) { if (!S.next) S.next = t + .8 + c.rand() * 1.2; if (t < S.next) return false; const dist = Math.abs(h.x - d.x), pool = []; for (const n of ['attack', 'skill2', 'skill1']) { if (n !== 'attack' && d.cooldowns[n] > 0) continue; const m = c.move(n); if (m.guard) continue; if (m.projectile ? dist >= 220 : dist <= c.reach(n) - 8) pool.push([n, n === 'attack' ? 3 : 1]); } if (!pool.length) { d.vx = Math.sign(h.x - d.x) * 240; loco(d, dt); return true; } // terlalu jauh dan tak ada tembakan siap: terus mendekat (S.next tetap, dinilai ulang tiap langkah) let r = c.rand() * pool.reduce((a, p) => a + p[1], 0), pick = pool[0][0]; for (const [n, w] of pool) { if ((r -= w) < 0) { pick = n; break; } } S.next = t + (c.act(pick, 1, true) ? .9 + c.rand() * 1.6 : .25); // serangan tunggal: tanpa rangkaian kombo otomatis return true; } const drive = guard(dt => { if (!S.on || S.beh === 'diam' || S.beh === 'cpu' || R.state === 'armed' || !usable()) return false; const g = G(), d = g.dummy, h = g.hero, c = g.dummyCtl, t = S.time; if (!c) return false; const grounded = d.y >= g.config.groundY - 1 && d.vy >= 0; if (!grounded) { // di udara: lompat ganda untuk "Lompat terus" bergantian, selain itu biarkan fisika berjalan if (S.beh === 'lompat' && S.dj && d.vy > 0 && d.jumps < 2) { d.jumps = 2; d.vy = -g.config.doubleJumpSpeed; } return true; } d.facing = h.x >= d.x ? 1 : -1; if (S.beh === 'blok') { d.vx = 0; if (d.state !== 'crouch') { d.state = 'crouch'; d.stateTime = 0; } return true; } if (S.beh === 'lompat') { d.vx = 0; if (!S.next) S.next = t + .4; if (t < S.next) return false; c.jump(); S.dj = !S.dj; S.next = t + .75; return true; } if (S.beh === 'ikuti') { const dist = Math.abs(h.x - d.x), dir = Math.sign(h.x - d.x), want = 200; // menjaga jarak sekitar 200 px: mendekat bila jauh, mundur bila terlalu dekat d.vx = dist > want + 40 ? dir * (dist > 360 ? 420 : 240) : dist < want - 50 ? -dir * 240 : 0; loco(d, dt); return true; } if (S.beh === 'serang') return driveAttack(g, d, h, c, t, dt); return false; }); const blocks = () => S.on && !S.fail && S.beh === 'blok' && !window.Tutorial?.active; // ---- data frame serangan terakhir pemain ---- const cardName = slot => ($(`.skill-card[data-action="${slot}"] .skill-name`)?.textContent || '').trim(); function rangeText(a, m) { if (a.type === 'ultimate') return 'sinematik, tanpa jangkauan tetap'; if (a.special) return SPECIAL[a.special]?.range || '—'; if (!m) return '—'; if (m.tree && window.Browo?.tree) return `pohon ${window.Browo.tree.ahead} px di depan, radius ${window.Browo.tree.radius} px`; if (m.dance && window.Browo?.dance) return `lingkaran radius ${window.Browo.dance.radius} px di sekitar`; if (m.area) return `lingkaran radius ${m.radius} px, pusat ${m.ahead || 0} px di depan, tinggi ±${m.vy || 150} px`; if (m.kind === 'lob') return 'lemparan melengkung, kolam di titik jatuh'; if (m.projectile) return `terbang sampai ${m.range} px`; if (m.dash) return `${Math.round((m.reach || 120) + 27)} px di depan, meluncur ±${Math.round(m.dash * m.duration * .4)} px`; if (a.type === 'attack') return `${Math.round(m.reach + 27)} px di depan`; return '—'; } // Rumus waktu kena sama dengan step() di game.js: duration x (hitAt, atau .53 untuk skill 2 dan .5 selain itu). function describe(a) { const g = G(), type = a.type, idx = a.index || 1; const m = type === 'attack' && !a.special ? (g.kitMove?.('attack', idx) || a) : a; const dur = a.duration || m.duration || 0, su = dur * (a.hitAt || (type === 'skill2' ? .53 : .5)); let name, sub; if (a.special) ({ name, sub } = SPECIAL[a.special] || { name: String(a.special).toUpperCase(), sub: '' }); else if (type === 'attack') { name = cardName('attack') || 'SERANGAN DASAR'; sub = `pukulan ke-${idx}, tombol SPASI`; } else { name = cardName(type) || type.toUpperCase(); sub = type === 'skill1' ? 'skill 1, tombol I' : type === 'skill2' ? 'skill 2, tombol O' : 'ultimate, tombol P'; } const rows = []; if (type === 'ultimate') rows.push(['Pembuka', ms(dur)], ['Sesudahnya', 'rangkaian sinematik; tak ada data frame tetap']); else { rows.push(['Startup', ms(su)]); if (a.dance && window.Browo?.dance) { const D = window.Browo.dance; rows.push(['Aktif', `${D.pulses} denyut, tiap ${ms(D.gap)}`]); } else rows.push(['Aktif', 'sesaat (±8 ms)']); rows.push(['Recovery', ms(Math.max(0, dur - su))], ['Total', ms(dur)]); if (m.projectile && m.range && m.speed) rows.push(['Terbang', `±${ms(m.range / m.speed)} sampai ujung`]); } rows.push(['Jangkauan', rangeText(a, m)]); if (m.damage) rows.push(['Damage dasar', String(m.damage)]); return { name, sub, rows }; } const fdHtml = f => (f ? `
${esc(f.name)}${esc(f.sub)}
Belum ada serangan. Pukul atau pakai skill dulu.
'); // ---- rekam dan putar ulang ---- const stats = () => { const s = G().stats?.() || {}; return { dmg: s.totalDamage || 0, best: s.bestCombo || 0 }; }; const say = (msg, tone = '') => { R.msg = msg; R.tone = tone; }; function internalReset() { S.busy = true; try { G().reset(); } finally { S.busy = false; } S.next = 0; S.dj = false; } function startRec() { if (!usable()) return; stopAll(); internalReset(); R.keep = { ev: R.ev, dur: R.dur, ref: R.ref, beh: R.beh, inf: R.inf, res: R.res }; // rekaman lama tetap ada bila Rekam dibatalkan sebelum tombol pertama R.state = 'armed'; R.ev = []; R.down.clear(); R.res = null; R.ref = null; R.dur = 0; R.beh = S.beh; R.inf = S.inf; say('Siap. Tekan tombol apa saja untuk mulai merekam (maksimal 10 dtk).', 'hint'); } function stopRec(why) { if (R.state === 'armed') { R.state = 'idle'; if (R.keep) Object.assign(R, R.keep); R.keep = null; say(R.ev.length ? 'Perekaman dibatalkan; rekaman sebelumnya masih ada.' : 'Perekaman dibatalkan.', ''); return; } if (R.state !== 'rec') return; const rel = S.time - R.t0; for (const k of R.down) R.ev.push({ t: rel, k, d: 0 }); R.down.clear(); R.dur = rel; R.ref = stats(); R.state = 'idle'; say(R.ev.length ? (why === 'batas' ? 'Batas 10 dtk tercapai. ' : '') + 'Rekaman siap. Tekan PUTAR.' : 'Tidak ada input terekam.', R.ev.length ? 'good' : 'bad'); } function startPlay() { if (!usable() || !R.ev.length) return; stopAll(); S.beh = R.beh; S.inf = R.inf; syncAi(); internalReset(); G().rng?.set(SEED); R.state = 'play'; R.p0 = null; R.i = 0; R.res = null; say('Memutar ulang... jangan menekan tombol.', 'hint'); } function stopAll() { if (R.state === 'rec' || R.state === 'armed') stopRec(); else if (R.state === 'play') { R.state = 'idle'; say('Putar ulang dihentikan.', ''); } } const send = e => { const g = G(); if (e.k.startsWith('cmd:')) g.input?.(e.k, true); else if (e.d) g.press(e.k); else g.release(e.k); }; function playStep(time) { if (R.p0 === null) R.p0 = time; const rel = time - R.p0 + 1e-6; while (R.i < R.ev.length && R.ev[R.i].t <= rel) send(R.ev[R.i++]); if (rel < R.dur) return; const now = stats(), ref = R.ref || now, same = now.dmg === ref.dmg && now.best === ref.best; R.state = 'idle'; R.res = { same, now, ref }; say(same ? `Putar selesai: SAMA seperti rekaman (damage ${now.dmg}, kombo terbaik ${now.best}).` : `Putar selesai: BERBEDA dari rekaman. Damage ${ref.dmg} jadi ${now.dmg}, kombo terbaik ${ref.best} jadi ${now.best}. Waktu atau jarak sedikit bergeser; coba rekam ulang.`, same ? 'good' : 'bad'); } // Kait press/release/forceCommand/clearInput: key = tombol (huruf kecil), 'cmd: