diff --git a/ai.js b/ai.js new file mode 100644 index 0000000..dbebed8 --- /dev/null +++ b/ai.js @@ -0,0 +1,131 @@ +/* Rival adaptif (RENCANA 6.6, ai.js): CPU mengingat kebiasaan pemain dalam SATU pertandingan (sejak startMatch; Arcade dan Survival memulai ingatan baru tiap lawan) + lewat jendela bergulir, lalu menggeser sedikit taktiknya. Mesin tidak diubah: game.js memanggil dua kait kecil dan tidak ada yang lain. + AIAdapt.reset(on, level) di startMatch (on = pertandingan versus). + AIAdapt.apply(P, dt, aktif, hero, dummy) di awal updateFenrAI: mencatat kebiasaan lalu mengembalikan profil CPU (P) yang sudah dikali pengali kecil. + Yang dicatat hanya fakta yang sudah terjadi di arena: pemain mulai melompat, mulai memblok, mulai pukulan dasar (rantai dihitung sekali), skill dekat, skill proyektil + atau jarak jauh. Tombol yang sedang ditekan tidak dibaca, dan sebuah kejadian baru "terlihat" oleh CPU LAG detik sesudahnya, jadi tidak ada yang dibaca sebelum terjadi. + Pengaruhnya kecil dan dibatasi: pengali antiAir, evade, punish, speed, penambah aggression, dan peluang skillBias (jurus didahulukan dari pukulan dasar). + Besarnya dikali kekuatan tingkat (easy hampir nol, excellent penuh) dan keyakinan (butuh sedikitnya MIN_N kejadian), dua arah: pemain yang jarang melompat + justru menurunkan sedikit peluang anti-udara. Damage, HP, jeda reaksi, dan batas kombo CPU tidak disentuh, dan tanpa pola yang jelas semua pengali = 1, + jadi perilaku lama (termasuk urutan random() mesin) tetap sama. Tidak memakai Math.random atau Date.now: hasilnya hanya bergantung pada keadaan permainan. + Layar hasil: AIAdapt.summary() = {kind, text} satu kalimat bila ada kebiasaan dominan; ResultView.mount dibungkus supaya kalimat itu muncul tanpa mengubah vs.js. + Matikan lewat konsol: AIAdapt.enabled = false. Intip keadaan: AIAdapt.state(). */ +(() => { + 'use strict'; + const WIN = 45; // detik permainan yang diingat (jendela bergulir) + const MAXEV = 36; // paling banyak kejadian yang diingat + const LAG = .3; // sebuah kejadian baru terbaca CPU sekian detik sesudah terjadi + const MIN_N = 8, FULL_N = 20; // kejadian minimum sebelum ada pengaruh, dan jumlah untuk keyakinan penuh + const STRENGTH = { easy: .1, medium: .5, hard: .85, excellent: 1 }; + // Titik nol penyetelan, bukan data nyata: porsi kejadian pemain "biasa" dan lebar rentangnya (porsi = acuan + lebar -> kecenderungan +1, porsi 0 -> -1 bila acuan = lebar). + const REF = { jump: [.16, .16], block: [.12, .12], zone: [.12, .12], melee: [.45, .3] }; + const KINDS = ['jump', 'block', 'melee', 'skill', 'zone']; + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + const empty = () => ({ jump: 0, block: 0, melee: 0, skill: 0, zone: 0 }); + const fresh = (on, level) => ({ on: !!on, level: level || 'medium', t: 0, ev: [], tot: empty(), n: 0, grounded: true, def: false, act: null, calcAt: 0, mul: null, seen: false }); + let S = fresh(false); + + // ---- mencatat kebiasaan ---- + function note(kind) { + S.seen = true; S.n++; S.tot[kind]++; + S.ev.push({ t: S.t, k: kind }); + while (S.ev.length > MAXEV || (S.ev.length && S.ev[0].t < S.t - WIN)) S.ev.shift(); + } + function classify(a, dist) { + if (a.type === 'attack') { if (a.special || a.index === 1) note('melee'); } // gerakan perintah, lempar, dan jurus toko juga hitung; sambungan kombo tidak + else if (a.type === 'skill1' || a.type === 'skill2') note(a.projectile || dist >= 330 ? 'zone' : 'skill'); // proyektil (tembakan, gelombang, kejar) atau dilepas dari jauh + } + function observe(dt, hero, dummy) { + S.t += dt; + const grounded = hero.grounded !== false; + if (S.grounded && !grounded) note('jump'); + S.grounded = grounded; + const def = !!hero.blocking || hero.parry > 0; + if (def && !S.def) note('block'); + S.def = def; + const a = hero.action; + if (a !== S.act) { S.act = a; if (a) classify(a, Math.abs(hero.x - dummy.x)); } + } + + // ---- membaca kebiasaan ---- + function tally(until) { + const c = empty(); let n = 0; + for (const e of S.ev) { if (e.t > until) break; c[e.k]++; n++; } + return { c, n }; + } + // kecenderungan -1..+1 untuk tiap kebiasaan terhadap porsi acuannya + function tend(c, n) { + const t = {}; for (const k of Object.keys(REF)) t[k] = n ? clamp((c[k] / n - REF[k][0]) / REF[k][1], -1, 1) : 0; + return t; + } + function strength() { return STRENGTH[S.level] ?? .5; } + function recalc() { + const { c, n } = tally(S.t - LAG); + if (n < MIN_N) { S.mul = null; return; } + const t = tend(c, n), s = strength() * clamp((n - MIN_N) / (FULL_N - MIN_N), 0, 1), pos = x => Math.max(0, x); + S.mul = { + antiAir: 1 + .35 * s * (t.jump > 0 ? t.jump : t.jump * .5), // sering melompat -> lebih sering menyambut di bawah; jarang melompat -> sedikit berkurang + evade: 1 + .12 * s * pos(t.zone) + .1 * s * pos(t.melee), // sering menembak dari jauh atau memukul dekat -> lebih sering menghindar + punish: 1 + .12 * s * pos(t.melee), // sering memukul dekat -> lebih sering menghukum jeda serangan + speed: 1 + .07 * s * pos(t.zone), // sering menembak dari jauh -> lebih cepat mendekat + aggr: .06 * s * pos(t.block), // sering memblok -> lebih berani menekan + skill: .25 * s * pos(t.block) // sering memblok -> kadang jurus didahulukan dari pukulan dasar (guard lebih cepat pecah) + }; + } + + // Dipanggil tiap langkah dari updateFenrAI. P = profil CPU baru (salinan), boleh diubah; mengembalikan P. + function apply(P, dt, active, hero, dummy) { + if (!active || !S.on || !api.enabled) return P; + try { + observe(dt, hero, dummy); + if (S.t >= S.calcAt) { S.calcAt = S.t + .25; recalc(); } + const m = S.mul; if (!m) return P; + P.antiAir = Math.min(1, P.antiAir * m.antiAir); P.evade = Math.min(1, P.evade * m.evade); P.punish = Math.min(1, P.punish * m.punish); + P.speed *= m.speed; P.aggression = Math.min(.99, P.aggression + m.aggr); P.skillBias = m.skill; + } catch (e) { console.error(e); api.enabled = false; } + return P; + } + function reset(on, level) { S = fresh(on, level); } + + // ---- kalimat untuk layar hasil ---- + const LINES = { + jump: ['kamu sering melompat, jadi dia mulai menengadah menunggu pendaratanmu.', 'kamu sering melompat, jadi dia mulai menghitung kapan kakimu mendarat.'], + block: ['kamu sering memblok, jadi dia berpikir perisai pun ada batas tahannya.', 'kamu sering memblok, jadi dia sabar mencari celah di balik blokmu.'], + zone: ['kamu sering menembak dari jauh, jadi dia mulai rajin lari pagi ke arahmu.', 'kamu sering menembak dari jauh, jadi dia belajar melompati tembakanmu sambil berlari.'], + melee: ['kamu sering menyerang dari dekat, jadi dia hafal kapan harus mundur selangkah.', 'kamu sering adu jotos dari dekat, jadi dia mulai pandai menghindar lalu membalas.'] + }; + // Kebiasaan dominan sepanjang pertandingan (bukan hanya jendela terakhir), atau null bila tidak ada yang menonjol. + function summary() { + if (!S.on || !S.seen || !api.enabled) return null; + const tot = S.tot, n = KINDS.reduce((a, k) => a + tot[k], 0); + if (n < 14) return null; + const t = tend(tot, n); let best = null; + for (const k of Object.keys(LINES)) if (t[k] >= .6 && (!best || t[k] > t[best])) best = k; + if (!best) return null; + const lead = S.level === 'easy' ? 'Rival mulai memperhatikan: ' : 'Rival belajar: '; + return { kind: best, text: lead + LINES[best][n % LINES[best].length] }; + } + function state() { + const { c, n } = tally(S.t); const t = tend(c, n); + return { on: S.on, level: S.level, t: +S.t.toFixed(2), n: S.n, total: { ...S.tot }, window: { n, ...c }, tend: t, mul: S.mul ? { ...S.mul } : null, strength: strength() }; + } + + // ---- layar hasil: satu baris di bawah kutipan pemenang (hanya di jendela yang cukup tinggi, lihat training.css) ---- + function decorate(root) { + const s = summary(), rs = root?.querySelector?.('.rs'); + if (!s || !rs || rs.querySelector('.rs-learn')) return; + const p = document.createElement('p'); p.className = 'rs-learn'; p.dataset.kind = s.kind; p.textContent = s.text; + (rs.querySelector('.rs-quote') || rs.querySelector('.rs-head'))?.insertAdjacentElement('afterend', p); + } + function hookResult() { + const R = window.ResultView; if (!R || R.mount?.__learn) return; + const base = R.mount; + R.mount = function (root) { const out = base.apply(this, arguments); try { decorate(root); } catch (e) { console.error(e); } return out; }; + R.mount.__learn = true; + } + // vs.js dimuat sesudah berkas ini: pasang pembungkus setelah semua skrip sinkron selesai (menu.js memanggil ResultView.mount sesudah itu). + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hookResult); else hookResult(); + + const api = { enabled: true, reset, apply, summary, state, decorate }; + window.AIAdapt = api; +})(); diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 1284bab..74a0e36 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -13,6 +13,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - Efek jurus (blok "Efek jurus (FX)" di game.js, dipakai pemain dan CPU lewat `cfStrike`): proyektil CF punya umur (`p.age`) dan gaya gambar `p.fx` (rings, shout, chain, roll, spray, flame, bull, drive, wave, bolt, ant); `fxPart` membuat partikel kosmetik (pengacak terpisah `fxr()`, bukan `random()`); jurus area punya aba-aba cincin 0,25 s (`fxTeleP`), gelombang kejut dan retakan; dash meninggalkan bayangan sprite (`cfx.ghosts`). Flag data di manifest per skill (juga `mode.skills`): `fx`, `lift`, `trail`, `split`, `dot`, `bind`, `convoy`, `aftershock`, `pull`, `rebound`, `swarm`+`ramp`, `fire`; artinya ada di komentar "Mekanik jurus" di game.js. - Rasa kena (`feel.js`, `window.Feel`): tabel penyetelan `Feel.table` per jenis serangan (ringan/sedang/berat/ultimate/guard/parry/counter: hit-stop, getaran, sentakan layar, kilat putih, ukuran angka, riak), `Feel.classify(damage, {counter, shake})`, K.O. slow-motion (`Feel.ko`, skala hanya untuk dt simulasi visual; jam ronde tetap waktu nyata), angka damage pop (maks 8) dan peringkat kombo `#combo-rank`. Titik pakai di game.js: `feelImpact` (satu pintu efek), `feelHeroHit` (CPU memukul pemain), `hitDummy` (pemain memukul lawan), `startKoFx`. - CPU: `updateFenrAI`, `cpuProfile` (tingkat easy/medium/hard/excellent dari `match.js`). + - CPU, jurus area pemusat-diri dan rival adaptif (RENCANA 6.5/6.6): `cpuAreaKit()` (jurus area siap yang jangkauannya tidak melebihi pukulan dasar: hanya TAWA MELEDAK Ethanol Man dan SARUNG PUTAR Solo Man; A Nice, `ahead` 110, tidak termasuk) dan `cpuAreaFits(P, m, dist)` dipakai `cpuPick`, yang kini mendahulukan jurus itu bila lawan di dalam lingkarannya (selisih 12 px, sejajar tinggi, tidak sedang berjalan menjauh, pukulan pemain yang sedang disiapkan tidak mendarat lebih dulu, pemain yang berjalan masuk dihitung seperti `walkIn`); di blok Movement `updateFenrAI`, bila jurus itu siap CPU berdiri sekitar `reach - 30` px (lebih rapat dari jarak pukulan dasar). Penyebab lama: pukulan dasar (161 px) selalu didahulukan di `cpuPick` dan menutup lingkaran 135/140 px, sedangkan penutup kombo `cpuFollowUp` tidak pernah lolos `cpuLands` (sisa stun lebih pendek dari 0,3 dtk waktu kena). Karakter lain tidak berubah (urutan `random()` mesin juga tetap). Batasan yang diketahui: CPU memukul dari jarak maksimum, jadi pemain yang menjaga jarak (seperti bot simulator, yang menjauh tepat saat CPU kebal) jarang masuk lingkaran; angka pemakaian skill 2 CPU di `tests/pairs.mjs` naik tetapi belum tinggi. `ai.js` (`window.AIAdapt`, lihat bagian "Latihan lanjutan dan rival adaptif" di bawah) mengubah salinan profil CPU di awal `updateFenrAI`. - Aturan ronde: `match.js` (`MatchRules`), dipakai `advanceMatch` di game.js. - `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`. - Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2/3.3): data di `content/passives.js` (`window.PASSIVES[id] = {name, desc, flaw, mods}`, `window.STYLES` agresif/seimbang/bertahan), helper dan teks angka otomatis di `passives.js` (`window.Passives`: `merge`, `describe`, `lockerHtml`, `decorateVS`). Mesin: `refreshPassives(styles)` di `startMatch` (juga `selectCharacter`, `setP2`) menggabungkan perk + kelemahan + gaya per sisi ke `sideMods`; semua pembacaan lewat satu titik kait `passiveMod(actor, key, base)` (hero = sisi pemain; selain itu = lawan, CPU atau P2, jadi simetris). Kunci: `dmgOut`, `dmgBasic`, `dmgSkill`, `dmgIn`, `walk`, `cd`, `cdSkill`, `cdUlt`, `parry`, `modeDur` (pengali) dan `regen` (HP/dtk, penambah). Damage dikali di `hitDummy`/`receiveHit`/`dotDamage` lewat `passiveDamage` (dibulatkan dengan sisa yang dibawa, tanpa pengacak); cooldown di loop cooldown `step`/`updateDummy`; jalan di `step`/`updateP2`/`updateFenrAI`; parry di `press('l')` dan peluang blok CPU di `hitDummy`. Gaya pemain disimpan di `Meta.save.style[id]` (`Meta.styleOf/setStyle`, simpanan lama tanpa kunci = Seimbang); `startMatch({styles:{player,enemy}})` menimpa, dan CPU di pertandingan ber-VS memakai gaya acak (pengacak terpisah `styleRand`). Teks tampil di menu Karakter (`renderLocker`), layar VS (`.vs-perk`), dan Daftar gerakan (`passiveRows`). API uji: `__game.passives()`, `__game.passiveMod(actor,key,base)`, `__game.setStyles({player,enemy})`. Catatan: P2 manusia tidak punya tombol blok, jadi kunci `parry` tidak berefek bagi P2. @@ -25,6 +26,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - PWA (RENCANA 4.7): `manifest.webmanifest` (id `konoha-fighting`, `display: fullscreen` + `display_override [fullscreen, standalone]`, landscape, warna `#101923`, ikon PNG any dan maskable 192/512) ditautkan di index.html dan mobile.html bersama `rel=icon` dan `apple-touch-icon`. Ikon di `assets/pwa/` dibuat `tools/make_pwa_icons.py` (hanya Pillow) dari `assets/menu/browo-select.webp`; jalankan ulang bila ingin mengganti gambar. `pwa.js` (`window.KonohaPWA = {available(), install(), onChange(fn)}`) dimuat sinkron di `` kedua halaman karena `beforeinstallprompt` datang ke dokumen paling atas: di HP itu `mobile.html`, dan iframe game memakai `window.parent.KonohaPWA` (panggilan langsung supaya gestur ketuk tetap berlaku untuk `prompt()`). Baris `#pwa-row` ("Pasang aplikasi") di Pengaturan hanya tampil bila event tersedia dan belum terpasang; iOS tidak punya event itu, jadi di sana tombol tidak tampil. `sw.js`: perilaku lama dipertahankan (gambar/font cache dulu, HTML/CSS/JS jaringan dulu); navigasi yang gagal dan tidak ada di cache mendapat `offline.html` dari cache kecil `konoha-offline-v1` (terpisah dari `aether-assets` karena `preload.js` menghapus entri yang tidak ada di `precache.js`). `tools/build_precache.py` kini memuat `manifest.webmanifest` dan `assets/pwa/*` (`offline.html`, `pwa.js`, `touchlayout.*` ikut otomatis); `precache.js`/`files.json` perlu dibangun ulang oleh integrator. mp3/mp4 tetap dilayani jaringan, jadi tanpa internet game jalan tanpa musik. Server perlu MIME `application/manifest+json` untuk `.webmanifest` (sudah di `.htaccess`). - Bantuan dan aksesibilitas (Tahap 4.2 dan 4.3): `access.js` (`window.Access`) + `access.css`, dimuat tepat sesudah `feel.js`. Menyisipkan kotak lipat "Bantuan & aksesibilitas" ke `#settings-panel` (tombol biasa, bukan `
`, karena penangan tombol game mencegat Spasi pada ``), setiap pilihan punya tombol Atur ulang (muncul bila nilainya bukan bawaan) plus "Atur ulang semua". Simpanan `localStorage['konoha.access.v1']` hanya berisi nilai non-bawaan; `konoha.access.open` mengingat kotak terbuka. Id pilihan: `combo`, `block`, `nocd`, `regen` (true/false), `text` (0 Normal, 1 Besar, 2 Sangat besar), `cb` (`off`|`pro`|`deu`|`tri`), `calm` (`auto` mengikuti prefers-reduced-motion | `on`), `hc` (true/false). Penerapan lewat kelas pada ``: `acc-t` + `acc-text-1|2` (variabel `--acc-z` teks kecil dan `--acc-zm` teks sedang, dipakai lewat `zoom` pada elemen teks saja), `acc-cb` + `acc-cb-pro|deu|tri` (filter SVG `#acc-cb-*` pada `#game-shell`, dibuat access.js; bar HP lawan bergaris dan lapisan cadangan bertitik), `acc-hc`, `acc-calm` (cermin aturan prefers-reduced-motion yang sudah ada di berkas CSS lain). Mesin membaca `window.Access` secara malas dan tanpa modul ini semua kait menjadi no-op. Kait di game.js, semuanya hanya sisi pemain (`hero`): `startAttack` (Kombo otomatis: pukulan 1 dibuat dengan `queued: 2`), `heroGuard` + `step` (Blok otomatis: serangan dari depan saat di tanah, tidak beraksi, guard belum pecah, masuk jalur blok biasa lalu `hero.autoGuard` menahan sikap blok 0,3 dtk; guard tetap bisa pecah), `step` (`Access.capCooldowns`: skill 1 dan 2 selalu 0, ultimate paling lama 5 dtk), `ultMax` dan pie cooldown (`Access.cdMax` agar bilah ikut batas 5 dtk), `useShopMove` (`shopCd` 0), `passiveRegen` (+2 HP/dtk, aturan 3 dtk tanpa terkena tetap berlaku), `draw`/`drawFlash`/`updateHud` (`Access.motion`: 0 = tanpa getaran layar, sentakan, zoom kamera Bro Wo, kilat sprite, potongan ultimate meluncur). `feel.js` memakai pengali yang sama lewat `motion()`/`calm()` (zoom K.O. dimatikan, kilat K.O. dimatikan, vignette tetap, animasi peringkat kombo berhenti) dan `vs.js` menghentikan hitung naik koin. Saklar "Animasi latar menu" dipadamkan lewat saklar aslinya (menu.js tidak diubah). Hadiah koin dan pencapaian tidak dibedakan saat bantuan aktif. Uji manual cepat di konsol: `Access.set('nocd', true)`, `Access.set('cb', 'deu')`, `Access.set('text', 2)`, `Access.state()`, `Access.reset()`. `precache.js`/`files.json` perlu dibangun ulang oleh integrator agar `access.js` dan `access.css` ikut tersimpan offline. - Tutorial singkat (Tahap 4.1): `tutorial.js` (`window.Tutorial`) + `tutorial.css`, dimuat di `index.html` tepat sebelum `menu.js`. Tombol TUTORIAL kecil di pojok kiri atas layar utama (dipasang `Tutorial.decorate(root, screen)`, dipanggil di akhir `render()` menu.js; tidak menambah baris tombol, jadi menu tetap muat di 520 px) memulai `startMatch({mode:'training', level:'easy', stage:'bellora'})` dengan karakter terakhir yang dimainkan (atau Mega Chan) melawan Soloman (atau Mega Chan) sebagai dummy diam. Delapan langkah (`STEPS` di tutorial.js: gerak, lompat, kombo, gerakan perintah, skill, blok dan parry, lempar, ultimate) dideteksi per frame dari `__game.hero` (`state`, `jumps`, `action.type/special`, `blocking`, `cooldowns`, `spCd`, `throwCd`) dan dua kait baru di game.js: `__game.stats()` (`comboHits`, `bestCombo`, `totalDamage`) serta `__game.dummyAttack(slow)` (serangan dasar pertama dummy, hanya di latihan; durasi dikali `slow` supaya pembuka parry terbaca; `index` disetel 3 supaya `cpuFollowUp` tidak merangkai serangan kedua). Hasil serangan dummy (parry, blok, kena) dibaca dari pembungkus `__game.receiveHit` yang dipasang tutorial (hanya mengamati, tidak mengubah hasil). Selesai = +50 koin satu kali (`Meta.addCoins`); tandanya `Meta.save.eggs['code:tutorial']` (awalan `code:` supaya tidak dihitung sebagai kejutan oleh `mystery.js`). Panel `#tut-panel` ada di `#game-shell`, tengah atas di bawah header HUD (letaknya mengikuti tiga ukuran header di style.css); `#announcement` digeser ke bawah panel lewat `body.tut-on` dan `--tut-bottom`. Tutorial berhenti sendiri bila menu utama terbuka (`body.in-menu`). Di `html.touch` teks dan sasaran memakai tombol layar. Belum ada skenario uji otomatis; cek cepat di konsol: `Tutorial.state()`, `Tutorial.start({player:'browo'})`. +- Latihan lanjutan dan rival adaptif (RENCANA 6.5 dan 6.6): `training.js` (`window.Training`) + `training.css` untuk panel dummy di arena latihan, `ai.js` (`window.AIAdapt`) untuk ingatan rival; tag-nya satu baris sendiri sebelum `` di `index.html` (sesudah blok `stats.js`). **Panel Dummy**: tombol DUMMY dipasang ke bilah `.match-actions` (kelas `.training-only`, jadi hilang di versus dan menu; `body.tut-on` menyembunyikannya selama Tutorial dan semua kaitnya mati bila `Tutorial.active`). Isi: perilaku dummy (Diam bawaan, Blok terus, Lompat terus, Serang acak, Ikuti pemain, CPU penuh = saklar `#ai-toggle`/"Lawan aktif" yang sudah ada, tetap sinkron dua arah) dan Reset posisi; saklar Tampilkan hitbox (`__game.setHitboxes`, sinkron dengan `#hitbox-toggle`), Tampilkan frame data (startup/aktif/recovery/total dalam ms dari `duration x hitAt` seperti `step()`, jangkauan px, damage dasar, dibaca dari aksi pemain terakhir lewat `__game.kitMove`), Darah dummy tak terbatas (tidak pernah K.O.; terisi lagi 1 dtk sesudah kena terakhir); penghitung kombo/damage (`__game.stats()`, kini memuat `comboDamage`). **Rekam/Putar**: Rekam mereset arena (`__game.reset()`), menunggu tombol pertama, lalu menyimpan tombol yang diterima `press()` (plus `release`, `forceCommand` sentuh sebagai `cmd:`, dan `clearInput` = lepas semua) dengan waktu simulasi relatif, maksimal 10 dtk; Putar mereset arena yang sama, menyetel benih `random()` mesin (`__game.rng`) seperti saat rekam dimulai, dan menjalankan peristiwa dari kait `pre` di AWAL tiap `step()` (sebelum waktu maju, jadi efeknya sama dengan tombol yang datang di antara dua langkah). Hasil putar dibandingkan dengan hasil rekaman (damage dan kombo terbaik): SAMA atau BERBEDA. Tidak terekam: klik kartu skill di HUD. Kait di game.js, semuanya `window.Training?.` satu baris: `init` (startMatch), `pre` (awal `step`), `drive` (cabang dummy tanpa AI di `updateFenrAI`; hanya `match.mode === 'training'`), `blocks` (kondisi blok di `hitDummy`; dummy berpose `crouch`, pukulan yang menjatuhkan dan lemparan tetap tembus), `rec` (`press`, `release`, `forceCommand`, `clearInput`), `reset` (`resetCombat`). Akses ke mesin: `__game.dummyCtl` (`act`, `jump`, `move`, `reach`, `rand`), `__game.kitMove`, `__game.rng`. Konsol: `Training.state()`. **Rival adaptif**: `startMatch` memanggil `AIAdapt.reset(versus, level)`; `updateFenrAI` memanggil `AIAdapt.apply(cpuProfile(), dt, aktif, hero, dummy)` (aktif = CPU di versus, bukan P2 dan bukan latihan) yang mengamati pemain lewat keadaan arena (mulai melompat, mulai memblok, pukulan dasar index 1 atau gerakan perintah, skill dekat, skill proyektil/jauh >= 330 px) dalam jendela bergulir 45 dtk (paling banyak 36 kejadian, terbaca CPU 0,3 dtk sesudah terjadi) dan mengembalikan profil yang dikali pengali kecil dua arah: `antiAir` (pemain sering melompat), `evade` dan `speed` (sering menembak dari jauh), `evade` dan `punish` (sering memukul dekat), penambah `aggression` dan peluang `skillBias` yang diundi sekali per keputusan di `cpuPick(..., skillFirst)` (sering memblok). Kekuatan = tingkat (easy 0,1 / medium 0,5 / hard 0,85 / excellent 1) x keyakinan (butuh >= 8 kejadian, penuh di 20); porsi acuan "pemain biasa" di `REF` bukan data nyata. Tanpa pola semua pengali = 1 dan `skillBias` = 0, jadi perilaku dan urutan `random()` sama dengan sebelumnya. Layar hasil: `ai.js` membungkus `ResultView.mount` dan menyisipkan satu kalimat `.rs-learn` ("Rival belajar: kamu sering ...") bila ada kebiasaan dominan sepanjang pertandingan (>= 14 kejadian, kecenderungan >= 0,6), disembunyikan di jendela lebih pendek dari 600 px. Matikan lewat konsol: `AIAdapt.enabled = false`; intip: `AIAdapt.state()`. Berkas baru ikut `tools/build_precache.py` (integrator membangun ulang `precache.js`/`files.json`). Belum ada skenario otomatis (diuji manual). - `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`. - Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache). - `vs.js` (`window.VS`, `window.ResultView`) + `vs.css` + `content/quotes.js` (`window.QUOTES`: intro/win/lose per karakter): layar VS dan layar hasil. `startMatch({..., vs: true | {label, p2}})` menampilkan layar VS 2,2 dtk (hanya mode versus; latihan tidak); selama itu `vsHold` di game.js menahan `advanceMatch` dan cue 'Round 1' keluar tepat sekali saat VS selesai atau dilewati (klik/Spasi/Enter/Esc setelah 0,38 dtk). `startMatch` tanpa `vs` berperilaku seperti dulu. `menu.js` screen `result` memakai `ResultView.view/mount` (layar lama jadi cadangan); `Meta.award` menyimpan rincian di `Meta.lastAward`; `Meta.nextFight`/`finishRun` memakai VS dan layar hasil yang sama. Pemilih kutipan `VS.quote(id, 'intro'|'win'|'lose')` memakai pengacak kosmetik terpisah. API uji: `__game.vsActive()`, `__game.vsSkip()`. Menu depan yang tersembunyi dijamin hilang oleh `#front-menu[hidden]{display:none!important}` (menu.css); aturan `display` per `data-screen` tidak boleh menimpanya. Layar hasil memfokuskan wadah `.rs` (`tabindex=-1`), bukan tombol, agar Spasi/Enter dari pertarungan tidak langsung memicu MAIN LAGI (Tab tetap menjangkau tombol). `Meta.toast` menahan toast selama `VS.active` dan memunculkannya sesudah VS selesai. diff --git a/docs/balance-report.md b/docs/balance-report.md index 56d6e0d..5d9d19c 100644 --- a/docs/balance-report.md +++ b/docs/balance-report.md @@ -109,7 +109,7 @@ Putaran 4 menambah buff kecil BRO WO (belum tersentuh sebelumnya, terendah pada 1. **Rangkaian ultimate menentukan hampir semua ronde.** Terhadap sasaran pasif, ultimate pertama ditambah ultimate mode (pasca-ultimate) menghasilkan 110 sampai 148 damage dari 200 HP sebelum penyetelan (MEGA CHAN 148, A NICE 134, SON OF SOLO MAN 129, BRO WO 127, SOLO MAN 126, GUFRON 126, ETHANOL MAN 110), dan selama itu lawan terkunci. Bot yang dikunci CPU kehilangan rata-rata sekitar 155 HP per ronde dari rangkaian ultimate dan ultimate mode (diukur pada GUFRON dan MEGA CHAN sebagai CPU, 18 ronde masing-masing). Karena itu hampir semua penyetelan di atas menyentuh angka ultimate, dan satu atau dua damage per denyut bisa menggeser persen menang belasan poin. 2. **Penutup ultimate menjatuhkan lawan.** Penutup (finisher) membuat sasaran jatuh dan bangun sekitar 2,1 dtk, dan denyut ultimate mode yang jatuh pada waktu itu tidak mengenai (sasaran jatuh dianggap tidak hidup oleh `bwAlive`). Memakai ultimate mode segera setelah rangkaian pertama membuang beberapa denyut; bot menunggu lawan bangun. -3. **CPU hampir tidak pernah memakai skill 2 ETHANOL MAN dan SOLO MAN** (pada uji pasangan `tests/pairs.mjs`: SOLO MAN 0 dari 7 ronde, ETHANOL MAN 1 dari 7). Keduanya berjenis area tanpa `ahead` (jangkauan 135 dan 140), lebih pendek dari pukulan dasar (161), dan `cpuPick` selalu mendahulukan pukulan dasar, sedangkan penutup kombo (`cpuFollowUp`) praktis tidak pernah memenuhi `cpuLands` (sisa stun pemain lebih pendek dari waktu kena skill). Akibatnya kedua karakter ini lebih lemah sebagai lawan CPU daripada angka kitnya. Ini celah AI (mekanik), bukan angka; uji pasangan (`tests/pairs.mjs`) mencatatnya sebagai peringatan. Bila AI atau datanya diperbaiki (misalnya `ahead` di manifest), jalankan ulang simulator dan setel ulang kedua karakter. +3. **CPU hampir tidak pernah memakai skill 2 ETHANOL MAN dan SOLO MAN** (pada uji pasangan `tests/pairs.mjs`: SOLO MAN 0 dari 7 ronde, ETHANOL MAN 1 dari 7). Keduanya berjenis area tanpa `ahead` (jangkauan 135 dan 140), lebih pendek dari pukulan dasar (161), dan `cpuPick` selalu mendahulukan pukulan dasar, sedangkan penutup kombo (`cpuFollowUp`) praktis tidak pernah memenuhi `cpuLands` (sisa stun pemain lebih pendek dari waktu kena skill). Akibatnya kedua karakter ini lebih lemah sebagai lawan CPU daripada angka kitnya. Ini celah AI (mekanik), bukan angka; uji pasangan (`tests/pairs.mjs`) mencatatnya sebagai peringatan. Bila AI atau datanya diperbaiki (misalnya `ahead` di manifest), jalankan ulang simulator dan setel ulang kedua karakter. **Pembaruan (cabang wf/t6-ai, RENCANA 6.5):** `cpuPick` kini mendahulukan jurus area pemusat-diri bila lawan di dalam lingkarannya (`cpuAreaKit`, `cpuAreaFits`) dan CPU berdiri lebih rapat saat jurus itu siap; angka di tabel laporan ini dibuat SEBELUM perbaikan itu dan belum dijalankan ulang penuh, jadi persen menang ETHANOL MAN dan SOLO MAN (terutama sebagai CPU) bisa sedikit naik. 4. **Posisi bot punya keunggulan besar.** Bot menang 77 sampai 85% pada semua jalan, juga pada pertandingan cermin. Penyebabnya struktural (pemain kebal saat skill dan ultimate, bonus counter 25% hanya untuk pukulan pemain, urutan pemrosesan hero lebih dulu) ditambah bot yang cukup rapi. Persen menang per karakter menggabungkan kedua posisi sehingga keunggulan ini saling meniadakan, tetapi banyak pasangan berakhir 8-8-0 (bot menang di kedua posisi), yang berarti hasilnya ditentukan posisi dan bukan karakter. Resolusi pengukuran jadi lebih kasar; untuk keputusan penyetelan pakai `BAL_N` 12 atau lebih, atau lemahkan bot (`BAL_BOT`). 5. **Pasangan paling timpang** (jalan akhir): GUFRON atas BRO WO 88% (14-2), SON OF SOLO MAN atas ETHANOL MAN 75%, MEGA CHAN atas A NICE dan atas GUFRON 69%. Pasangan GUFRON dan BRO WO layak diperiksa lebih dulu pada penyetelan berikutnya. 6. **Kebijakan ultimate bot menentukan apa yang diukur.** Pada percobaan awal bot memakai ultimate sendiri pada detik ke-5 seperti CPU tanpa membalas, dan menang sekitar 93% ronde apa pun karakternya (siapa yang menekan duluan menang; tidak informatif). Maka bot kini membalas ultimate lawan dalam waktu reaksi: sekitar dua pertiga ronde menjadi adu rangkaian yang hampir bersamaan, dan sepertiga sisanya CPU lebih dulu (biasanya bot sedang terkena serangan atau sedang menjalankan skill saat CPU mulai). diff --git a/game.js b/game.js index aa3792e..0968988 100644 --- a/game.js +++ b/game.js @@ -245,7 +245,7 @@ setShadowFoe(!!options.shadow&&mode==='versus'); window.Daily?.arm?.(options.daily,{sideMods,balance:BALANCE,versus:mode==='versus'&&!options.shadow}); // tantangan harian (daily.js): aturan, HP, dan pengubah sisi; tanpa options.daily aturan dimatikan aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins(); - window.MatchStats?.reset(mode==='versus'); + window.MatchStats?.reset(mode==='versus');window.Training?.init?.(mode==='training');window.AIAdapt?.reset?.(mode==='versus',level); // latihan lanjutan (training.js) dan ingatan rival (ai.js) dimulai segar tiap pertandingan $('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus'); if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level,styles:{...sideStyle},shadow:shadowFoe})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3); return true; @@ -408,7 +408,7 @@ if (p2Mode && P2_KEYS.includes(key)) { p2Press(key); return; } if (!canAct() || keys.has(key) || (key === 'l' && window.Challenge?.noBlock)) return; // aturan "tanpa blok" (challenge.js): L tidak berbuat apa-apa if (key === 'l' && window.Daily?.deny?.('guard')) return; // Tanpa blok (daily.js): blok dan parry tidak pernah terdaftar - keys.add(key); logInput(key); + keys.add(key); logInput(key); window.Training?.rec?.(key, true, time); // rekaman latihan (training.js): hanya tombol yang diterima if (key === 'l') { hero.parry = .16 * passiveMod(hero, 'parry'); } if (key === 'j') startThrow(); if (key === 'a' || key === 'd') { const direction = key === 'a' ? -1 : 1; if (time - taps[key] <= CONFIG.doubleTapWindow) hero.runDirection = direction; else if (hero.runDirection !== direction) hero.runDirection = 0; taps[key] = time; taps[key === 'a' ? 'd' : 'a'] = -10; } @@ -592,11 +592,12 @@ dummy.vx = approach(dummy.vx, dir * CONFIG.walkSpeed * passiveMod(dummy, 'walk'), dt * 2800); dummy.state = dir ? 'walk' : 'idle'; dummy.walkPhase = (dummy.walkPhase || 0) + Math.abs(dummy.vx) * dt / 148; } - function forceCommand(id) { const c = COMMANDS[id]; if (!c) return false; inputLog.length = 0; const f = hero.facing > 0 ? 'd' : 'a'; if (id === 'uppercut') inputLog.push({ k: 's', t: time - .1 }, { k: 's', t: time }); else inputLog.push({ k: 's', t: time - .1 }, { k: f, t: time }); return startCommand(); } + function forceCommand(id) { const c = COMMANDS[id]; if (!c) return false; inputLog.length = 0; const f = hero.facing > 0 ? 'd' : 'a'; if (id === 'uppercut') inputLog.push({ k: 's', t: time - .1 }, { k: 's', t: time }); else inputLog.push({ k: 's', t: time - .1 }, { k: f, t: time }); const ok = startCommand(); if (ok) window.Training?.rec?.('cmd:' + id, true, time); return ok; } const CPU_BLOCK = { easy: .05, medium: .14, hard: .24, excellent: .32 }; - function release(key) { key = key.toLowerCase(); keys.delete(key); keys2.delete(key); if ((key === 'a' && hero.runDirection === -1) || (key === 'd' && hero.runDirection === 1)) hero.runDirection = 0; } - function clearInput() { keys.clear(); keys2.clear(); hero.runDirection = 0; taps.a = taps.d = taps.s = -10; } + function release(key) { key = key.toLowerCase(); window.Training?.rec?.(key, false, time); keys.delete(key); keys2.delete(key); if ((key === 'a' && hero.runDirection === -1) || (key === 'd' && hero.runDirection === 1)) hero.runDirection = 0; } + function clearInput() { window.Training?.rec?.(null, false, time); keys.clear(); keys2.clear(); hero.runDirection = 0; taps.a = taps.d = taps.s = -10; } function resetCombat() { + window.Training?.reset?.(); systemAnnouncer?.clear(); stopUltimateVoice(); Object.assign(hero, { x: 350, y: CONFIG.groundY, vx: 0, vy: 0, facing: 1, grounded: true, jumps: 0, state: 'idle', animTime: 0, walkPhase: 0, smokeDistance:0, action: null, runDirection: 0, hp: BALANCE.heroMax, koTime:0, invuln: 0, hurtTime: 0, doublePose: 0, rollDuration: CONFIG.rollDuration, rollBuffer: null, airTime: 0, cooldowns: { skill1: 0, skill2: 0, ultimate: 0 } }); @@ -619,7 +620,8 @@ if (dummy.action?.guard && shellGuard(dummy)) return false; if (damage > 0) damage = Math.max(1, passiveDamage(hero, dummy, damage, hitKind === 'basic')); dummy.lastHurt = time; const dir = sourceX <= dummy.x ? 1 : -1; - if (!ignoreBlock && F && aiEnabled && !p2Mode && match?.mode === 'versus' && dummy.y >= CONFIG.groundY - 1 && !dummy.action && (dummy.state === 'idle' || dummy.state === 'walk') && !knockdown && random() < (CPU_BLOCK[difficulty] ?? .1) * passiveMod(dummy, 'parry')) { + // Blok CPU di versus (peluang menurut tingkat), atau dummy "Blok terus" di latihan (training.js: berpose jongkok, selalu menahan kecuali lemparan dan serangan yang menjatuhkan). + if (!ignoreBlock && F && dummy.y >= CONFIG.groundY - 1 && !dummy.action && (dummy.state === 'idle' || dummy.state === 'walk' || dummy.state === 'crouch') && !knockdown && ((aiEnabled && !p2Mode && match?.mode === 'versus' && random() < (CPU_BLOCK[difficulty] ?? .1) * passiveMod(dummy, 'parry')) || window.Training?.blocks?.())) { const chip = Math.min(Math.max(1, Math.round(damage * .2)), Math.max(0, BALANCE.dummyMax - dummy.damage - 1)); dummy.damage += chip; totalDamage += chip; window.MatchStats?.hit(hitSource(), false, 0, hero.action); spawnParticles(dummy.x - dir * 30, impactY, '#ffd36a', 8, 180); damageNumbers.push({ x: dummy.x, y: impactY - 40, amount: 'GUARD', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard }); sound('step'); feelImpact('dummy', 'guard', { dir, x: dummy.x - dir * 30, y: impactY }); return true; @@ -1830,6 +1832,7 @@ function step(realDt) { systemAnnouncer?.tick(realDt); if (menuOpen || paused || settingsOpen) return; + window.Training?.pre?.(time, realDt); // latihan lanjutan: jadwal putar ulang, darah dummy tak terbatas, aksi terakhir pemain (di awal langkah, sebelum waktu maju) // K.O. slow motion (feel.js) hanya memperlambat simulasi visual (dt); jam ronde/penutup K.O. di advanceMatch tetap waktu nyata. Feel.tick(realDt); const dt = realDt * Feel.ko.scale(); hero.flash = Math.max(0, (hero.flash || 0) - realDt); dummy.flash = Math.max(0, (dummy.flash || 0) - realDt); @@ -2340,8 +2343,34 @@ // Fastest move that reaches the player now (melee first, then area skill), or null. // The player walking in or pressing buttons: poke from the full reach of attack1 to win the exchange. function cpuPoking() { const toward = Math.sign(dummy.x - hero.x); return hero.vx * toward > 40 || !!hero.action; } - function cpuPick(P, dist, allowSkill) { - const options = [['attack', 1], ...(allowSkill ? [['skill2', 1], ['skill1', 1]] : [])]; + // Jurus area yang berpusat di penyerang (TAWA MELEDAK, SARUNG PUTAR: ahead 0) jangkauannya LEBIH PENDEK daripada pukulan dasar. Dengan urutan "pukulan dulu" di cpuPick ia tidak + // pernah terpilih: tiap jarak yang cukup dekat untuknya juga dijangkau pukulan dasar, dan penutup kombo (cpuFollowUp) tidak pernah lolos cpuLands karena sisa stun lebih pendek dari + // waktu kenanya (docs/balance-report.md, temuan 3). cpuAreaKit = jurus area siap yang tidak melampaui pukulan dasar (A NICE, ahead 110, sudah melampauinya dan tidak termasuk). + function cpuAreaKit() { + const atk = cpuReach(cpuMove('attack', 1)); + for (const name of ['skill2', 'skill1']) { + if (dummy.cooldowns[name] > 0) continue; + const m = cpuMove(name); + if (m.area && !m.guard && cpuReach(m) <= atk) return { name, m }; + } + return null; + } + // Apakah ledakan yang dilepas sekarang akan kena: lawan di dalam lingkaran (selisih 12 px), sejajar tinggi, tidak sedang berjalan keluar, dan pukulan pemain yang sedang + // disiapkan tidak mendarat lebih dulu (pukulan itu membatalkan ledakan). Pemain yang berjalan masuk dihitung seperti jurus lain (walkIn). + function cpuAreaFits(P, m, dist) { + const t = cpuHitTime(m), vx = hero.vx * Math.sign(dummy.x - hero.x), ha = hero.action, edge = cpuReach(m) - 12; + if (Math.abs(hero.y - dummy.y) > (m.vy || 150) - 15) return false; + if (dist > edge + Math.max(0, vx) * t * P.evade) return false; + if (vx < -60 && dist - vx * t > edge) return false; + if (ha && !ha.fired && ha.type === 'attack' && dist < attackRange(ha.index) + 37 && ha.duration * (ha.hitAt || .5) - ha.t < t) return false; + return true; + } + function cpuPick(P, dist, allowSkill, skillFirst = false) { + let options = [['attack', 1], ...(allowSkill ? [['skill2', 1], ['skill1', 1]] : [])]; + const ak = allowSkill ? cpuAreaKit() : null; + if (ak && cpuAreaFits(P, ak.m, dist)) options.unshift([ak.name, 1]); + // Rival belajar (ai.js): bila pemain sering memblok, kadang jurus didahulukan dari pukulan dasar (skillFirst diundi sekali per keputusan di updateFenrAI). + else if (allowSkill && skillFirst) options = [...options.slice(1), options[0]]; for (const [name, index] of options) { if (name !== 'attack' && dummy.cooldowns[name] > 0) continue; const m = cpuMove(name, index); @@ -2372,7 +2401,8 @@ } } function updateFenrAI(dt) { - const P = cpuProfile(); + // Rival belajar (ai.js): hanya di versus melawan CPU; mencatat kebiasaan pemain dan mengembalikan profil yang digeser sedikit (profil lain di game.js, mis. batas kombo, tidak disentuh). + const P = window.AIAdapt?.apply?.(cpuProfile(), dt, aiEnabled && !p2Mode && match?.mode === 'versus', hero, dummy) || cpuProfile(); dummy.aiTime += dt; dummy.aiThink = Math.max(0, dummy.aiThink - dt); if (['hurt', 'down', 'recover'].includes(dummy.state)) { dummy.action = null; cpu.plan = null; dummy.aiThink = Math.max(dummy.aiThink, P.reaction + P.wake); return; } if (dummy.action) { @@ -2387,7 +2417,10 @@ } const airborne = dummy.y < CONFIG.groundY || dummy.vy < 0; if (p2Mode) { updateP2(dt); return; } - if (!aiEnabled || hero.hp <= 0) { dummy.vx = approach(dummy.vx, 0, dt * 1500); if (!airborne) dummy.state = 'idle'; return; } + if (!aiEnabled || hero.hp <= 0) { + if (!aiEnabled && hero.hp > 0 && match?.mode === 'training' && window.Training?.drive?.(dt)) return; // dummy latihan (training.js): blok, lompat, serang acak, ikuti pemain + dummy.vx = approach(dummy.vx, 0, dt * 1500); if (!airborne) dummy.state = 'idle'; return; + } if (airborne) { if (cpu.doubleAt && time >= cpu.doubleAt && dummy.jumps < 2) { dummy.jumps = 2; dummy.vy = -CONFIG.doubleJumpSpeed; cpu.doubleAt = 0; } dummy.state = 'jump'; return; @@ -2465,6 +2498,7 @@ if (cpu.plan && pick && (open || !(pa.type === 'attack')) && cpuLands(pick.m, true) && cpuHitTime(pick.m) - Math.max(0, hero.action ? hero.action.duration - hero.action.t : 0) < .15 && cpuStart(pick.name, pick.index)) return; if (cpu.plan && !hero.action && !pick && hero.invuln <= 0 && time - cpu.heroActionAt > 1.2) cpu.plan = null; } + const allowSkill = P.skillEvery === 1 || Math.floor(dummy.aiTime / 2) % P.skillEvery === 0; // Movement: retreat plan, close in (run when far) or rush a punish, hold just outside a startup, or wait in reach // while the player is protected. const reach = (cpuPoking() ? cpuReach(cpuMove('attack', 1)) - 6 : cpuChainReach(P)) + 8, protectedNow = hero.invuln > 0 || hero.hurtTime > 0 || (ha && (ha.type.startsWith('skill') || ha.type === 'ultimate')); @@ -2481,16 +2515,17 @@ const immuneFor = hero.hurtTime > 0 ? 0 : Math.max(hero.invuln, ha && (ha.type.startsWith('skill') || ha.type === 'ultimate') ? ha.duration - ha.t : 0); const closeIn = Math.max(0, dist - reach) / (420 * P.speed) + cpuHitTime(cpuMove('attack', 1)) + .05; const outside = attackRange(1) + 27 + 20, keepOut = (threat && (punishWait || (!rushing && cpu.read === ha && cpu.respect))) || immuneFor > closeIn; - const goal = keepOut ? Math.max(reach, threat ? threat + 18 : outside) : reach - (protectedNow && hero.hurtTime > 0 ? 20 : 12); + // Jurus area pemusat-diri siap (TAWA MELEDAK, SARUNG PUTAR): berdiri cukup dekat supaya lingkarannya mengenai (lebih rapat dari jarak pukulan dasar), lalu cpuPick melepasnya. + const ak = !keepOut && !protectedNow && allowSkill ? cpuAreaKit() : null; + const goal = keepOut ? Math.max(reach, threat ? threat + 18 : outside) : ak ? Math.min(cpuReach(ak.m) - 30, reach - 12) : reach - (protectedNow && hero.hurtTime > 0 ? 20 : 12); wanted = dist > goal ? dummy.facing * (dist > 300 || (rushing && !punishWait) || immuneFor > 0 ? 420 : 240) : dist < Math.min(55, goal - 20) ? -dummy.facing * 150 : keepOut && dist < goal - 6 ? -dummy.facing * 300 : 0; } const ws = passiveMod(dummy, 'walk'); dummy.vx = approach(dummy.vx, clamp(wanted * P.speed * ws, -520 * ws, 520 * ws), dt * 2200); dummy.walkPhase += Math.abs(dummy.vx) * dt / 150; const next = Math.abs(dummy.vx) > 5 ? (Math.abs(dummy.vx) > 300 ? 'run' : 'walk') : 'idle'; if (next !== dummy.state) { dummy.state = next; dummy.stateTime = 0; } if (dummy.aiThink > 0 || cpu.plan?.kind === 'chain' || cpu.plan?.kind === 'sidestep') return; // 4. Decisions on the think timer. - const allowSkill = P.skillEvery === 1 || Math.floor(dummy.aiTime / 2) % P.skillEvery === 0; if (dummy.aiTime > P.ultimateAfter && dummy.form === 'human' && dummy.cooldowns.ultimate <= 0 && cpuStart('ultimate')) { dummy.aiThink = P.reaction; return; } - const pick = cpuPick(P, dist, allowSkill); + const pick = cpuPick(P, dist, allowSkill, P.skillBias > 0 && random() < P.skillBias); if (pick && random() < P.aggression && (cpuLands(pick.m, true) || (hero.hurtTime <= 0 && random() < P.mistakes))) { if (cpuStart(pick.name, pick.index)) { if (pick.name === 'attack') dummy.aiChain++; dummy.aiThink = P.reaction; return; } } @@ -2936,7 +2971,7 @@ const h=hpLayers(heroHpShown()), dh=hpLayers(dummyHpShown()); $('#health-fill').style.width = `${h.front}%`; $('#health-reserve').style.width = `${h.reserve}%`; $('#health-label').textContent = `${hero.hp} / ${BALANCE.heroMax}`; $('#guard-fill').style.width = `${dh.front}%`; $('#guard-reserve').style.width = `${dh.reserve}%`; $('#guard-label').textContent = `HP ${currentDummyHP()} / ${BALANCE.dummyMax}`; - $('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING'); + $('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING', crouch: 'GUARDING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING'); $('#combo').classList.toggle('visible', comboHits > 0); $('#combo-count').textContent = comboHits; $('#combo-damage').textContent = `${comboDamage} DAMAGE`; Feel.comboHud({ hits: comboHits, timer: comboTimer, timerMax: 2.3, show: comboHits > 0 && (match?.mode !== 'versus' || match.phase === 'fight') }); window.Meta?.hud?.({ move: SHOP_MOVES[window.Meta?.equippedMove?.()], cd: hero.shopCd || 0, sp: hero.spCd || 0 }); $('#total-damage').innerHTML = `${totalDamage} DMG`; $('#best-combo').innerHTML = `${bestCombo} HITS`; $('#announcement').classList.toggle('visible', announceTimer > 0); @@ -3020,7 +3055,7 @@ // Tutorial (RENCANA 4.1, tutorial.js). stats() = kait baca kombo dan damage latihan. dummyAttack(slow) = perintah serangan dasar pertama dummy, hanya di latihan // (bukan P2): menghadap pemain, durasi dikali slow supaya pembukanya terbaca untuk latihan parry; index disetel 3 supaya cpuFollowUp tidak merangkai serangan kedua. Object.assign(window.__game, { - stats: () => ({ comboHits, bestCombo, totalDamage }), + stats: () => ({ comboHits, bestCombo, totalDamage, comboDamage }), dummyAttack(slow = 1) { if (menuOpen || paused || p2Mode || match?.mode !== 'training' || hero.hp <= 0 || dummy.action || dummy.y < CONFIG.groundY - 1 || ['hurt', 'down', 'recover'].includes(dummy.state)) return false; dummy.facing = hero.x >= dummy.x ? 1 : -1; @@ -3040,6 +3075,14 @@ cpuRead() { const P = cpuProfile(); return { reaction: P.reaction, evade: P.evade }; }, cpuDodge(dir, dur) { if (!aiEnabled || p2Mode || dummy.action || dummy.y < CONFIG.groundY - 1 || ['hurt', 'down', 'recover'].includes(dummy.state) || !(dur > 0)) return false; cpu.plan = { kind: 'sidestep', dir, until: time + dur }; return true; } }; + // Latihan lanjutan (training.js): akses sempit ke dummy (aksi CPU, lompat, data gerakan, jangkauan, pengacak mesin), data jurus pemain, dan benih random() mesin untuk rekam-putar ulang. + // dummyCtl.act(name, index, single): hadapkan pemain lalu mulai aksi dummy; single = serangan tunggal (index 3 supaya cpuFollowUp tidak merangkai serangan kedua, seperti dummyAttack). + Object.assign(window.__game, { + dummyCtl: { jump: () => cpuJump(false), move: (name, index = 1) => cpuMove(name, index), reach: (name, index = 1) => cpuReach(cpuMove(name, index)), rand: () => random(), + act(name, index = 1, single = false) { dummy.facing = hero.x >= dummy.x ? 1 : -1; if (!cpuStart(name, index)) return false; if (single && dummy.action?.type === 'attack') dummy.action.index = 3; return true; } }, + kitMove: (name, index = 1) => { try { return selectedCharacter === 'fenr' ? F.move(hero, name, index) : KITS[selectedCharacter]?.move(name, index) || null; } catch (_) { return null; } }, + rng: { get: () => seed, set: v => { seed = v >>> 0; } } + }); if(F)reset(); if(window.FRONTEND_ENABLED)setMenuOpen(true); resize(); requestAnimationFrame(tick); diff --git a/index.html b/index.html index 3a2dacd..9f41116 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,8 @@
KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
+ +
diff --git a/tests/pairs.mjs b/tests/pairs.mjs index a09fe3f..3aec885 100644 --- a/tests/pairs.mjs +++ b/tests/pairs.mjs @@ -4,8 +4,9 @@ // skill 2, dan ultimate sedikitnya sekali di sisi bot DAN sisi CPU, data peringkat content/tiers.js, dan bagian peringkat di menu Koleksi. import { installBalance } from './bot.mjs'; -// Celah CPU yang sudah diketahui: AI memilih pukulan dasar lebih dulu, dan skill 2 berjenis area tanpa `ahead` (jangkauannya lebih pendek dari pukulan dasar) tidak pernah terpilih. -// Lihat docs/balance-report.md. Bila datanya disetel supaya CPU memakainya, peringatan berubah jadi catatan agar daftar ini dibersihkan. +// Celah CPU yang sudah diketahui: skill 2 berjenis area tanpa `ahead` (jangkauannya lebih pendek dari pukulan dasar) dulu tidak pernah terpilih karena pukulan dasar didahulukan. +// Sejak cpuAreaKit/cpuAreaFits (game.js, RENCANA 6.5) CPU memakainya bila lawan masuk lingkarannya, tetapi hanya di sebagian ronde (bot simulator menjauh tepat saat CPU bebas), jadi +// tetap dicatat sebagai peringatan (tidak menggagalkan). Lihat docs/balance-report.md. Bila CPU sudah memakainya di lebih dari separuh ronde, peringatan berubah jadi catatan agar daftar ini dibersihkan. const KNOWN_CPU_GAPS = { ethanolman: ['skill 2'], soloman: ['skill 2'] }; export default async ({ page, game, characters }) => { diff --git a/training.css b/training.css new file mode 100644 index 0000000..fb2c87a --- /dev/null +++ b/training.css @@ -0,0 +1,62 @@ +/* Latihan lanjutan (training.js, RENCANA 6.5): tombol DUMMY di bilah kecil bawah jam dan panel dummy di arena latihan; juga satu baris "Rival belajar" di layar hasil (ai.js, RENCANA 6.6). + Panel memakai kelas .training-only (menu.css), jadi hilang sendiri di versus dan di menu; body.tut-on (tutorial.js) menyembunyikannya selama tutorial. + Tinggi minimum yang didukung 520 px: panel boleh digulir di dalamnya dan tidak pernah menutupi kartu skill di bawah. */ + +/* ---- tombol di bilah .match-actions (menimpa ukuran tombol ikon 29 x 27 px) ---- */ +.match-actions #dm-btn{width:auto;min-width:0;padding:4px 9px;font:700 11px/1 Rajdhani,Arial,sans-serif;letter-spacing:1.6px;color:#e9dcc6;cursor:pointer} +.match-actions #dm-btn[hidden]{display:none} +.match-actions #dm-btn.on{background:#e6c78f;color:#102734;border-color:#fff0c8} +body.tut-on #dm-btn,body.tut-on #dm-panel{display:none!important} + +/* ---- panel: sisi kiri (bisa dipindah ke kanan) di bawah header HUD, jauh dari penghitung kombo besar di kanan; letaknya mengikuti tiga ukuran header seperti panel tutorial. z-index 2: timer mode (.form-timers, z-index 3, tembus klik) tetap terlihat di atasnya ---- */ +#dm-panel{--dm-fs:12px;position:absolute;left:14px;top:156px;z-index:2;width:236px;max-height:calc(100% - 156px - 128px);overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;box-sizing:border-box;padding:7px 10px 10px;color:#fff8e8;background:linear-gradient(180deg,#0d2432f2,#0a1b27ee);border:1px solid #e6c78f55;border-top:3px solid #e6c78f;font:600 var(--dm-fs)/1.25 Rajdhani,Arial,sans-serif;pointer-events:auto;box-shadow:0 6px 22px #0008} +#dm-panel[hidden]{display:none} +#dm-panel[data-side="right"]{left:auto;right:14px} +.dm-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +.dm-head b{font-size:13px;letter-spacing:2.2px;color:#f3d796} +.dm-head span{display:flex;gap:4px} +.dm-mini{width:22px;height:22px;padding:0;display:grid;place-items:center;font:700 12px/1 Arial,sans-serif;color:#e6ecea;background:#13303d;border:1px solid #7dada177;cursor:pointer} +.dm-mini:hover,.dm-mini:focus-visible{background:#e6c78f;color:#102734;border-color:#fff0c8} +.dm-sec{margin-top:7px;padding-top:6px;border-top:1px solid #ffffff1f} +.dm-sec h3{margin:0 0 4px;font-size:10px;letter-spacing:2.4px;color:#e6c78f} +.dm-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px} +.dm-b,.dm-sw{font:700 var(--dm-fs)/1.2 Rajdhani,Arial,sans-serif;letter-spacing:.5px;color:#e6ecea;background:#13303d;border:1px solid #7dada177;cursor:pointer} +.dm-b{padding:4px 5px;text-align:center} +.dm-b:hover,.dm-b:focus-visible,.dm-sw:hover,.dm-sw:focus-visible{background:#2a5566;border-color:#e6c78faa} +.dm-b[aria-checked="true"],.dm-b.on{background:#e6c78f;color:#102734;border-color:#fff0c8} +.dm-b:disabled,.dm-sw:disabled{opacity:.45;cursor:default} +.dm-b:disabled:hover,.dm-sw:disabled:hover{background:#13303d;border-color:#7dada177} +.dm-wide{display:block;width:100%;margin-top:4px} +.dm-sw{display:flex;align-items:center;gap:8px;width:100%;margin-top:3px;padding:3px 6px;text-align:left} +.dm-sw i{flex:none;width:26px;height:14px;position:relative;background:#0a1b27;border:1px solid #7dada1aa;border-radius:8px} +.dm-sw i:after{content:"";position:absolute;left:2px;top:2px;width:8px;height:8px;border-radius:50%;background:#9fb3c4;transition:transform .12s,background .12s} +.dm-sw[aria-checked="true"] i{background:#1d6b52;border-color:#8dffa8} +.dm-sw[aria-checked="true"] i:after{transform:translateX(12px);background:#8dffa8} +.dm-count{display:flex;flex-direction:column;gap:1px;margin:6px 0 0;font-size:11px;letter-spacing:.4px;color:#cfdedd} +.dm-count b{color:#ffd36a} +.dm-fd dl{display:grid;grid-template-columns:auto 1fr;gap:1px 10px;margin:0} +.dm-fd dt{color:#9fb3c4;font-size:11px} +.dm-fd dd{margin:0;text-align:right;color:#fff3d4} +.dm-mv{margin:0 0 3px;display:flex;flex-direction:column;line-height:1.15} +.dm-mv b{font-size:13px;color:#ffd36a;letter-spacing:.6px} +.dm-mv small{font-size:10px;color:#9fb3c4;letter-spacing:.8px} +.dm-rec{display:grid;grid-template-columns:1.3fr 1fr 1fr;gap:4px} +.dm-rec .dm-b{padding:4px 3px;white-space:nowrap} +.dm-note{margin:5px 0 0;min-height:1.3em;font-size:11px;font-weight:600;letter-spacing:.2px;color:#d6e4e6} +.dm-note.hint{color:#ffd36a}.dm-note.good{color:#8dffa8}.dm-note.bad{color:#ff9d8f} + +@media(min-width:1200px) and (min-height:640px){#dm-panel{--dm-fs:13px;width:262px}.dm-head b{font-size:14px}.dm-fd dt,.dm-note,.dm-count{font-size:12px}} +@media(min-width:1600px){#dm-panel{top:170px;max-height:calc(100% - 170px - 140px)}} +@media(max-width:950px){#dm-panel{top:137px;width:224px;max-height:calc(100% - 137px - 120px)}} +@media(max-width:620px){#dm-panel{top:118px;width:204px;max-height:calc(100% - 118px - 112px)}} +@media(max-height:500px) and (min-width:620px){#dm-panel{--dm-fs:11px;top:118px;width:216px;max-height:calc(100% - 118px - 104px);padding:5px 8px 8px}.dm-sec{margin-top:5px;padding-top:4px}.dm-b{padding:3px 4px}.dm-sw{padding:2px 6px}.dm-note{margin-top:3px}} +html.touch .dm-b,html.touch .dm-sw{padding-top:6px;padding-bottom:6px} +html.touch .dm-mini{width:28px;height:28px} +html.touch .match-actions #dm-btn{padding:6px 12px} +@media(max-height:500px){html.touch .dm-b,html.touch .dm-sw{padding-top:4px;padding-bottom:4px}} +@media(prefers-reduced-motion:reduce){.dm-sw i:after{transition:none}} + +/* ---- layar hasil: satu baris "Rival belajar" di bawah kutipan (ai.js). Disembunyikan di jendela pendek supaya tata letak 520 px tidak bergeser ---- */ +.rs-learn{margin:0;padding:3px 10px;border-left:3px solid #8bd4c3;font:600 italic clamp(11px,1.4vh + 3px,14px)/1.25 Rajdhani,sans-serif;letter-spacing:.3px;color:#cfe9e2;background:linear-gradient(90deg,#0b1b25aa,#0b1b2500);animation:rs-up .5s .55s both} +@media(max-height:600px){.rs-learn{display:none}} +@media(prefers-reduced-motion:reduce){.rs-learn{animation:none}} diff --git a/training.js b/training.js new file mode 100644 index 0000000..8213935 --- /dev/null +++ b/training.js @@ -0,0 +1,283 @@ +/* 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 + ? `

SERANGAN TERAKHIR

${esc(f.name)}${esc(f.sub)}

${f.rows.map(([k, v]) => `
${esc(k)}
${esc(v)}
`).join('')}
` + : '

FRAME DATA

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 = Math.max(0, 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:' untuk gerakan perintah sentuh, atau null = semua tombol dilepas. + const rec = guard((key, down, time) => { + if (!S.on || (R.state !== 'armed' && R.state !== 'rec')) return; + if (R.state === 'armed') { + if (key === null || !down) return; + R.state = 'rec'; R.keep = null; R.t0 = time; R.ev = []; R.down.clear(); R.beh = S.beh; R.inf = S.inf; S.next = 0; S.dj = false; G().rng?.set(SEED); say('', ''); + } + const rel = time - R.t0; + if (key === null) { for (const k of R.down) R.ev.push({ t: rel, k, d: 0 }); R.down.clear(); return; } + if (key.startsWith('cmd:')) { if (down) R.ev.push({ t: rel, k: key, d: 1 }); return; } + if (down) { R.down.add(key); R.ev.push({ t: rel, k: key, d: 1 }); } else if (R.down.delete(key)) R.ev.push({ t: rel, k: key, d: 0 }); + }); + + // ---- kait mesin ---- + const init = isTraining => { + S.on = !!isTraining; S.fail = false; S.beh = 'diam'; S.inf = false; S.fd = false; S.next = 0; S.dj = false; S.last = null; S.fdv = null; S.cmb = { live: false, hits: 0, dmg: 0, last: null }; + R.state = 'idle'; R.keep = null; R.ev = []; R.down.clear(); R.res = null; R.ref = null; R.dur = 0; say('', ''); S.key = ''; + }; + const reset = () => { + if (S.busy || !S.on) return; + if (R.state === 'rec' || R.state === 'armed') stopRec(); else if (R.state === 'play') { R.state = 'idle'; say('Putar ulang dihentikan (arena direset).', ''); } + S.next = 0; S.last = null; + }; + const pre = guard(time => { + if (!S.on || S.fail) return; + S.time = time; + if (window.Tutorial?.active) return; + const g = G(), h = g.hero, d = g.dummy; + if (h.action !== S.last) { S.last = h.action; if (h.action) { try { S.fdv = describe(h.action); S.fdn++; } catch (e) { S.fdv = null; console.error(e); } } } + if (S.inf && !d.ko) { const max = g.balance.dummyMax; if (d.damage > max * .8) d.damage = max * .8; if (d.damage > 0 && time - (d.lastHurt ?? -99) > 1) d.damage = 0; } + if (R.state === 'rec') { if (time - R.t0 >= MAXREC) stopRec('batas'); } else if (R.state === 'play') playStep(time); + }); + + // ---- tampilan ---- + const aiOn = () => !!$('#ai-toggle')?.checked; + const hbOn = () => !!$('#hitbox-toggle')?.checked; + function syncAi() { // 'CPU penuh' memakai saklar Lawan aktif yang sudah ada, supaya Pengaturan dan panel tidak saling bertentangan + const t = $('#ai-toggle'), want = S.beh === 'cpu'; + if (t && t.checked !== want) { t.checked = want; t.dispatchEvent(new Event('change')); } + } + function setBeh(b) { if (R.state !== 'idle') return; S.beh = b; S.next = 0; S.dj = false; syncAi(); } + function resetPos() { + if (R.state !== 'idle') return; + const g = G(), h = g.hero, d = g.dummy; h.x = 350; h.vx = 0; h.facing = 1; d.x = 915; d.vx = 0; d.facing = -1; S.next = 0; + } + const arena = () => $('#arena')?.focus({ preventScroll: true }); + function act(a, v) { + const g = G(); if (!g) return; + if (a === 'toggle') { S.open = !S.open; save(); } + else if (a === 'close') { S.open = false; save(); } + else if (a === 'side') { S.side = S.side === 'left' ? 'right' : 'left'; save(); } + else if (a === 'beh') setBeh(v); + else if (a === 'pos') resetPos(); + else if (a === 'hb') g.setHitboxes?.(!hbOn()); + else if (a === 'fd') S.fd = !S.fd; + else if (a === 'inf') { if (R.state === 'idle') S.inf = !S.inf; } + else if (a === 'rec') { if (R.state === 'armed' || R.state === 'rec') stopRec(); else startRec(); } + else if (a === 'play') { if (R.state === 'play') stopAll(); else startPlay(); } + else if (a === 'stop') stopAll(); + S.key = ''; refresh(); arena(); + } + function build() { + if (S.ui) return; + const nav = $('.match-actions'), shell = $('#game-shell'); if (!nav || !shell) return; + const btn = document.createElement('button'); btn.id = 'dm-btn'; btn.type = 'button'; btn.className = 'training-only'; btn.textContent = 'DUMMY'; btn.title = 'Panel dummy latihan'; + btn.setAttribute('aria-controls', 'dm-panel'); btn.setAttribute('aria-expanded', 'false'); btn.dataset.a = 'toggle'; btn.hidden = true; + const p = document.createElement('section'); p.id = 'dm-panel'; p.className = 'training-only'; p.hidden = true; p.dataset.side = S.side; p.setAttribute('role', 'region'); p.setAttribute('aria-label', 'Panel dummy latihan'); + const sw = (a, t) => ``; + p.innerHTML = `
DUMMY LATIHAN
` + + `

PERILAKU

${BEH.map(([id, t]) => ``).join('')}
` + + `

ALAT BANTU

${sw('hb', 'Tampilkan hitbox')}${sw('fd', 'Tampilkan frame data')}${sw('inf', 'Darah dummy tak terbatas')}

` + + `` + + `

REKAM & PUTAR ULANG

`; + // Tombol tidak boleh merebut fokus dari arena: tombol yang fokus menelan SPASI (serangan) sebagai klik. + for (const el of [btn, p]) { el.addEventListener('mousedown', e => { if (e.target.closest('button')) e.preventDefault(); }); el.addEventListener('click', e => { const b = e.target.closest('[data-a]'); if (b && !b.disabled) act(b.dataset.a, b.dataset.v); }); } + nav.append(btn); shell.append(p); + S.ui = { btn, p, fd: p.querySelector('#dm-fd'), note: p.querySelector('#dm-note'), count: p.querySelector('#dm-count') }; + } + function refresh() { + if (!S.ui) return; + const U = S.ui, show = usable(); + if (!show) { if (!U.btn.hidden) { U.btn.hidden = true; U.p.hidden = true; } S.key = ''; return; } + const g = G(), ai = aiOn(); + if (ai && S.beh !== 'cpu') S.beh = 'cpu'; else if (!ai && S.beh === 'cpu') S.beh = 'diam'; // Pengaturan mengubah Lawan aktif + const busy = R.state !== 'idle', st = g.stats?.() || { comboHits: 0, bestCombo: 0, totalDamage: 0, comboDamage: 0 }; + if (st.comboHits > 0) { S.cmb.live = true; S.cmb.hits = st.comboHits; S.cmb.dmg = st.comboDamage || 0; } else if (S.cmb.live) { S.cmb.live = false; S.cmb.last = { hits: S.cmb.hits, dmg: S.cmb.dmg }; } + const relNow = Math.max(0, R.state === 'rec' ? S.time - R.t0 : R.state === 'play' && R.p0 !== null ? S.time - R.p0 : 0); + const hb = hbOn(); + const key = JSON.stringify([S.open, S.side, S.beh, S.inf, S.fd, hb, R.state, R.ev.length, Math.floor(relNow * 5), R.dur, R.msg, S.cmb.live, S.cmb.hits, S.cmb.dmg, S.cmb.last, st.bestCombo, st.totalDamage, S.fd ? S.fdn : 0]); + if (key === S.key) return; S.key = key; + U.btn.hidden = false; U.btn.setAttribute('aria-expanded', String(S.open)); U.btn.classList.toggle('on', S.open); + U.p.hidden = !S.open; U.p.dataset.side = S.side; if (!S.open) return; + for (const b of U.p.querySelectorAll('[data-a="beh"]')) { const on = b.dataset.v === S.beh; b.setAttribute('aria-checked', String(on)); b.disabled = busy; } + U.p.querySelector('[data-a="pos"]').disabled = busy; + const sw = { hb, fd: S.fd, inf: S.inf }; + for (const b of U.p.querySelectorAll('.dm-sw')) { b.setAttribute('aria-checked', String(!!sw[b.dataset.a])); b.disabled = b.dataset.a === 'inf' && busy; } + const cb = S.cmb.live ? `${S.cmb.hits} hit · ${S.cmb.dmg} dmg` : S.cmb.last ? `terakhir ${S.cmb.last.hits} hit · ${S.cmb.last.dmg} dmg` : 'belum ada'; + U.count.innerHTML = `KOMBO ${cb}terbaik ${st.bestCombo} · total ${st.totalDamage} dmg`; + U.fd.hidden = !S.fd; if (S.fd) U.fd.innerHTML = fdHtml(S.fdv); + const bs = { rec: U.p.querySelector('[data-a="rec"]'), play: U.p.querySelector('[data-a="play"]'), stop: U.p.querySelector('[data-a="stop"]') }; + bs.rec.innerHTML = R.state === 'armed' || R.state === 'rec' ? '■ Selesai' : '● Rekam'; bs.rec.disabled = R.state === 'play'; bs.rec.classList.toggle('on', R.state === 'armed' || R.state === 'rec'); + bs.play.innerHTML = R.state === 'play' ? '▶ Memutar...' : '▶ Putar'; bs.play.disabled = R.state === 'rec' || R.state === 'armed' || !R.ev.length; bs.play.classList.toggle('on', R.state === 'play'); + bs.stop.disabled = R.state === 'idle'; + let msg = R.msg, tone = R.tone; + if (R.state === 'rec') { msg = `Merekam ${num(relNow)} / ${MAXREC} dtk · ${R.ev.length} input`; tone = 'hint'; } + else if (R.state === 'play') { msg = `Memutar ${num(Math.min(relNow, R.dur))} / ${num(R.dur)} dtk. Jangan menekan tombol.`; tone = 'hint'; } + else if (R.state === 'idle' && !msg) msg = R.ev.length ? `Rekaman: ${num(R.dur)} dtk · ${R.ev.length} input. Tekan PUTAR.` : 'Belum ada rekaman. Tekan REKAM, lalu mainkan urutanmu.'; + U.note.className = 'dm-note' + (tone ? ' ' + tone : ''); U.note.textContent = msg; + } + + function ready() { build(); refresh(); setInterval(refresh, 120); document.getElementById('hitbox-toggle')?.addEventListener('change', () => { S.key = ''; }); } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready); else ready(); + + window.Training = { + init, pre, drive, blocks, rec, reset, + open: () => { S.open = true; save(); S.key = ''; refresh(); }, close: () => { S.open = false; save(); S.key = ''; refresh(); }, + state: () => ({ on: S.on, fail: S.fail, behavior: S.beh, infinite: S.inf, frameData: S.fd, open: S.open, side: S.side, rec: { state: R.state, events: R.ev.length, duration: +R.dur.toFixed(2), reference: R.ref, result: R.res }, lastMove: S.fdv }) + }; +})();