diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 248a9a5..8a2e931 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -19,6 +19,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`. - Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa. - `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`. +- 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'})`. - `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/game.js b/game.js index f5184aa..696140f 100644 --- a/game.js +++ b/game.js @@ -2979,6 +2979,18 @@ // shadowFoe() melaporkan apakah pertandingan sekarang melawan Bayangan (uji manual). { const baseHit = window.__game.receiveHit; window.__game.receiveHit = (damage, options) => baseHit(foeDamage(damage), options); } window.__game.shadowFoe = () => shadowFoe; + // 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 }), + 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; + if (!startEnemyAction('attack', 1)) return false; + if (dummy.action) { dummy.action.duration *= Math.max(1, slow); dummy.action.index = 3; } + return true; + } + }); if(F)reset(); if(window.FRONTEND_ENABLED)setMenuOpen(true); resize(); requestAnimationFrame(tick); diff --git a/index.html b/index.html index ad9e67f..60556ea 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,7 @@ Konoha Fighting - +
Menyiapkan…

@@ -24,6 +24,6 @@
✦KONOHA FIGHTING

Menyiapkan arena…
KONOHA FIGHTING / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat (2× W = lompat ganda)   S · Menunduk
SPACE · Kombo 3 pukulan   ↓ ↓ + SPACE · Uppercut   ↓ → + SPACE · Tinju maju
I / O · Skill   P · Ultimate   K · Jurus toko
L · Blok (ketuk = parry)   J · Lempar
Esc · Jeda, berisi daftar gerakan karakter   R · Mulai ulang
Dua pemain (P2): ← → · Gerak   ↑ · Lompat   N · Serang   M / , / . · Skill 1 / 2 / Ultimate
Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.

KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
-
+
diff --git a/menu.js b/menu.js index 4951584..da2260f 100644 --- a/menu.js +++ b/menu.js @@ -75,6 +75,7 @@ const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`
${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}
${fighters[winner].name}
${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH

${won?'VICTORY':'DEFEAT'}

${fighters[winner].name} WINS

${result.playerWins}—${result.enemyWins}
${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}
`; } bind();attachBackground();window.Brand?.decorate(root,screen);window.Mystery?.decorate(root,screen,hover,lockedSlots); + window.Tutorial?.decorate(root,screen); // tombol TUTORIAL kecil di pojok kiri atas layar utama (tutorial.js) } function updatePreview(play=true){ root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover)); diff --git a/tutorial.css b/tutorial.css new file mode 100644 index 0000000..9e3e582 --- /dev/null +++ b/tutorial.css @@ -0,0 +1,67 @@ +/* Tutorial singkat (tutorial.js, RENCANA 4.1): tombol di menu utama, panel instruksi di arena, dan tanda "!" untuk latihan parry. + Tinggi minimum yang didukung 520 px (1024x520); ada aturan ringkas untuk jendela pendek dan ponsel lanskap (844x390). */ + +/* ---- Tombol TUTORIAL di menu utama: pojok kiri atas, di dalam bilah .home-top tetapi di luar alur (absolut, tengah vertikal), sehingga + tinggi bilah dan letak tombol-tombol menu tidak berubah sedikit pun (menu tetap muat di 520 px) ---- */ +#front-menu .home-top{position:relative} +#front-menu .tut-entry{position:absolute;left:4.5%;top:50%;z-index:1;display:inline-flex;align-items:center;gap:8px;min-height:26px;padding:3px 14px 3px 10px;background:#102b37c4;border:1px solid #d7b87d77;border-left:3px solid #d7b87d;color:#f6eedb;font:700 13px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:2.5px;cursor:pointer;transform:translateY(-50%) skewX(-7deg);transition:background .14s,border-color .14s,transform .14s} +#front-menu .tut-entry>*{transform:skewX(7deg)} +#front-menu .tut-entry i{font-style:normal;font-size:11px;color:#d7b87d} +#front-menu .tut-entry:not(.done){animation:tut-nudge 2.2s ease-in-out infinite} +#front-menu .tut-entry:hover,#front-menu .tut-entry:focus-visible{background:#f0d49d;color:#122837;border-color:#fff0c8;transform:translateY(-50%) skewX(-7deg) translateX(4px);animation:none} +#front-menu .tut-entry:hover i,#front-menu .tut-entry:focus-visible i{color:#122837} +#front-menu .tut-entry.done{border-left-color:#8dffa8}#front-menu .tut-entry.done i{color:#8dffa8} +@keyframes tut-nudge{0%,100%{box-shadow:0 0 0 #d7b87d00}50%{box-shadow:0 0 14px #d7b87d88}} +html.touch #front-menu .tut-entry{min-height:32px} +@media(max-width:800px){#front-menu .tut-entry{left:6%}} +@media(max-height:420px){#front-menu .tut-entry{min-height:22px;padding:2px 10px 2px 8px;font-size:11px;letter-spacing:2px}html.touch #front-menu .tut-entry{min-height:28px}} + +/* ---- Panel instruksi di arena: tengah atas, di bawah header HUD dan tombol jeda; letaknya mengikuti tiga ukuran header di style.css ---- */ +#tut-panel{--tt:18px;--tx:15px;--gl:14px;--kb:12px;position:absolute;left:50%;top:156px;z-index:4;width:min(540px,calc(100vw - 340px));transform:translateX(-50%);box-sizing:border-box;padding:8px 14px 10px;color:#fff8e8;background:linear-gradient(180deg,#0d2432f0,#0a1b27ea);border:1px solid #e6c78f55;border-top:3px solid #e6c78f;box-shadow:0 8px 26px #0009;pointer-events:none;font-family:Rajdhani,'Arial Narrow',sans-serif} +#tut-panel.fin{border-top-color:#8dffa8} +.tut-head{display:flex;align-items:center;gap:10px;min-height:26px} +.tut-step{flex:none;font-size:11px;font-weight:700;letter-spacing:2px;color:#e6c78f;white-space:nowrap} +.tut-title{flex:1;min-width:0;margin:0;font-size:var(--tt);line-height:1.1;font-weight:700;font-style:italic;letter-spacing:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:2px 2px 0 #13232c} +#tut-panel.fin .tut-title{color:#8dffa8} +.tut-actions{flex:none;display:flex;gap:6px} +.tut-btn{pointer-events:auto;padding:3px 9px;font:700 11px/1.2 Rajdhani,Arial,sans-serif;letter-spacing:1.4px;color:#e6ecea;background:#13303d;border:1px solid #7dada177;cursor:pointer} +.tut-btn:hover,.tut-btn:focus-visible{background:#e6c78f;color:#102734;border-color:#fff0c8} +.tut-free{background:#e6c78f;color:#102734;border-color:#fff0c8} +.tut-btn[hidden],.tut-goals[hidden]{display:none} +.tut-text{margin:3px 0 6px;min-height:1.25em;font-size:var(--tx);line-height:1.25;color:#d6e4e6} +.tut-text.hint{color:#ffd36a}.tut-text.bad{color:#ff9d8f}.tut-text.good{color:#8dffa8} +.tut-goals{display:flex;flex-wrap:wrap;gap:6px 8px;margin:0;padding:0;list-style:none} +.tut-goal{display:flex;align-items:center;gap:7px;min-height:26px;padding:2px 10px 2px 6px;font-size:var(--gl);font-weight:700;letter-spacing:.4px;background:#102b37c4;border:1px solid #7dada144;border-left:3px solid #7fa69877} +.tut-goal .box{flex:none;width:16px;height:16px;display:grid;place-items:center;font-style:normal;font-size:12px;line-height:1;color:transparent;border:2px solid #9fb3c4} +.tut-goal.done{background:#12362bd6;border-left-color:#8dffa8} +.tut-goal.done .box{background:#8dffa8;border-color:#8dffa8;color:#0c2230} +.tut-goal.done.new .box{animation:tut-pop .3s ease-out} +.tut-goal.done .lab{color:#b9ffcd} +.tut-goal .keys{display:flex;align-items:center;gap:3px} +.tut-key{display:inline-block;min-width:20px;padding:1px 6px;font:700 var(--kb)/18px Rajdhani,Arial,sans-serif;text-align:center;background:#ecddbb;color:#102731;box-shadow:0 2px 0 #8d7f5f} +.tut-goal .plus{font-size:12px;color:#9fb3c4} +.tut-goal .lab small{font-size:.85em;font-weight:500;letter-spacing:0;color:#ffd36a} +.tut-bar{position:absolute;left:0;right:0;bottom:0;height:3px;background:#ffffff1c} +.tut-bar i{display:block;height:100%;width:0;background:linear-gradient(90deg,#86d9c4,#e0bf85);transition:width .35s ease-out} +.tut-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +@keyframes tut-pop{0%{transform:scale(.4)}70%{transform:scale(1.3)}100%{transform:scale(1)}} +/* Pengumuman besar (UPPERCUT!, PARRY!, ...) turun ke bawah panel supaya tidak tertutup. --tut-bottom diisi tutorial.js. */ +body.tut-on #announcement{top:calc(var(--tut-bottom,160px) + 6px)} + +/* ---- Tanda "!" di atas kepala dummy, 0,3 dtk sebelum pukulannya kena (ketuk L sekarang). Posisi dan skala diisi tutorial.js ---- */ +.tut-cue{position:absolute;z-index:4;left:0;top:0;width:46px;display:none;transform:translate(-50%,-100%) scale(var(--cs,1));transform-origin:50% 100%;text-align:center;pointer-events:none} +.tut-cue.on{display:block} +.tut-cue b{display:block;width:46px;height:46px;border-radius:50%;font:700 34px/46px Rajdhani,Arial,sans-serif;color:#2a1a00;background:#ffd36a;box-shadow:0 0 0 4px #ff6a5acc,0 0 24px #ffb15a;animation:tut-cue .22s ease-in-out infinite alternate} +.tut-cue small{display:block;margin-top:3px;font:700 13px/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#fff3d4;text-shadow:0 1px 3px #000} +@keyframes tut-cue{from{transform:scale(.92)}to{transform:scale(1.08)}} + +/* ---- Ukuran: layar lebar, header besar, header ringkas, jendela pendek (<= 500 px) ---- */ +@media(min-width:1200px) and (min-height:640px){#tut-panel{--tt:21px;--tx:17px;--gl:16px;--kb:13px;width:min(660px,calc(100vw - 560px));padding:10px 16px 12px}.tut-step{font-size:12px}.tut-btn{padding:4px 11px;font-size:12px}.tut-goal{min-height:30px}} +@media(min-width:1600px){#tut-panel{top:170px}} +@media(max-width:950px){#tut-panel{top:137px;width:min(520px,calc(100vw - 300px))}} +@media(max-width:620px){#tut-panel{top:118px;width:calc(100vw - 24px)}} +@media(max-height:500px) and (min-width:620px){#tut-panel{--tt:15px;--tx:13px;--gl:12px;--kb:11px;top:118px;width:min(520px,calc(100vw - 340px));padding:6px 12px 8px}.tut-head{min-height:22px}.tut-text{margin:2px 0 5px}.tut-goal{min-height:22px;padding:1px 8px 1px 5px}.tut-key{line-height:16px}.tut-btn{padding:2px 7px;font-size:10px}} +html.touch .tut-btn{padding:5px 10px} +@media(max-height:500px){html.touch .tut-btn{padding:4px 9px}} + +@media(prefers-reduced-motion:reduce){#front-menu .tut-entry,.tut-goal.done.new .box,.tut-cue b{animation:none!important}.tut-bar i{transition:none}} diff --git a/tutorial.js b/tutorial.js new file mode 100644 index 0000000..a6c8dec --- /dev/null +++ b/tutorial.js @@ -0,0 +1,301 @@ +/* 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 = `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 = '

'; + ui = { bar: panel.querySelector('.tut-bar i'), step: panel.querySelector('.tut-step'), title: panel.querySelector('.tut-title'), text: panel.querySelector('.tut-text'), goals: panel.querySelector('.tut-goals'), skip: panel.querySelector('.tut-skip'), free: panel.querySelector('.tut-free'), exit: panel.querySelector('.tut-exit'), sr: panel.querySelector('.tut-sr') }; + // Fokus tetap di kanvas: tombol yang sedang fokus akan menelan SPASI (serangan) sebagai klik. + const back = () => $('#arena')?.focus({ preventScroll: true }); + for (const [b, fn] of [[ui.skip, () => { skip(); back(); }], [ui.free, () => { stop(); back(); }], [ui.exit, exit]]) { b.onmousedown = e => e.preventDefault(); b.onclick = fn; } + cue = document.createElement('div'); cue.className = 'tut-cue'; cue.setAttribute('aria-hidden', 'true'); cue.innerHTML = '!L'; + host.append(panel, cue); + } + function sr(text) { if (ui) { ui.sr.textContent = ''; ui.sr.textContent = text; } } + // Tanda "!" di atas kepala dummy: koordinat dunia -> layar dengan rumus yang sama dengan resize() di game.js. + function cueAt(actor) { + if (!cue) return; + if (!actor) { cue.classList.remove('on'); return; } + const vw = innerWidth, vh = innerHeight, s = Math.min(vw / 1280, vh / 720), ox = (vw - 1280 * s) / 2, oy = vh * .815 - game().config.groundY * s; + cue.style.left = Math.round(ox + actor.x * s) + 'px'; cue.style.top = Math.round(oy + (actor.y - 185) * s) + 'px'; + cue.style.setProperty('--cs', String(Math.max(.7, Math.min(1.25, s * 1.15)))); cue.classList.add('on'); + } + function fit() { if (panel) document.body.style.setProperty('--tut-bottom', Math.round(panel.getBoundingClientRect().bottom) + 'px'); } + // Teks yang tampil: pesan sementara > petunjuk jarak/arah > teks langkah. + function message(c, st, base) { + if (S.say && S.clock < S.say.until) return S.say; + if (S.phase === 'done') return { text: S.i === STEPS.length - 1 ? 'Bagus! Tutorial hampir selesai…' : 'Bagus! Lanjut ke langkah berikutnya…', tone: 'good' }; + if (st.range) { + const gap = c.dummy.x - c.hero.x, d = side(c), key = touch() ? d.ts : d.kb; + if (Math.abs(gap) > st.range) return { text: `Jauh dari dummy. Jalan ke ${d.name} (${key}).`, tone: 'hint' }; + if (st.face && gap * c.hero.facing < -8) return { text: `Hadapkan karaktermu ke dummy: tekan ${key}.`, tone: 'hint' }; + } + return { text: base, tone: '' }; + } + function paint(force) { + if (!S || !ui) return; + const c = S.c || ctx(0), ts = touch(), fin = S.phase === 'finish', st = STEPS[S.i]; + let v; + if (fin) { + const n = S.skipped.length, tip = ' Tekan Esc kapan saja untuk daftar gerakan.'; + v = { step: 'SELESAI', title: 'TUTORIAL SELESAI', tone: 'good', pct: 100, goals: [], + text: (S.reward ? `Semua dasar sudah kamu coba. Hadiah +${COINS} koin masuk ke dompetmu.` : 'Semua dasar sudah kamu coba. Hadiah koin tutorial sudah pernah kamu ambil.') + (n ? ` ${n} langkah dilewati; ulangi dari menu kapan saja.` : tip) }; + } else { + const base = typeof st.text === 'function' ? st.text(c, ts) : (ts ? st.text.ts : st.text.kb), m = message(c, st, base); + v = { step: `LANGKAH ${S.i + 1}/${STEPS.length}`, title: st.title, tone: m.tone, text: m.text, pct: (S.i + (S.phase === 'done' ? 1 : 0)) / STEPS.length * 100, + goals: st.goals.map(x => { + const k = ts ? x.ts : x.kb, w = typeof x.wait === 'function' ? Math.ceil(x.wait(c) || 0) : 0, ok = !!S.goals[x.id]; + return { id: S.i + x.id, keys: typeof k === 'function' ? k(c) : k, label: typeof x.label === 'function' ? x.label(c) : x.label, ok, wait: ok ? 0 : w }; + }) }; + } + const key = JSON.stringify([v.step, v.title, v.tone, v.text, v.goals]); + if (!force && key === S.key) return; + S.key = key; + ui.step.textContent = v.step; ui.title.textContent = v.title; ui.text.textContent = v.text; ui.text.className = 'tut-text' + (v.tone ? ' ' + v.tone : ''); + ui.bar.style.width = v.pct + '%'; panel.classList.toggle('fin', fin); + ui.skip.hidden = fin; ui.free.hidden = !fin; ui.exit.textContent = fin ? 'KE MENU' : 'KELUAR ✕'; + ui.goals.hidden = fin; + // Animasi centang hanya untuk sasaran yang baru selesai (S.seen), bukan tiap panel digambar ulang. + ui.goals.innerHTML = v.goals.map(x => { const fresh = x.ok && !S.seen[x.id]; if (x.ok) S.seen[x.id] = 1; return `
  • ${x.keys.map(k => (k === '+' ? '+' : `${esc(k)}`)).join('')}${esc(x.label)}${x.wait ? ` tunggu ${x.wait} dtk` : ''}${x.ok ? ' selesai' : ''}
  • `; }).join(''); + fit(); + } + + window.Tutorial = { + start, stop, skip, decorate, done, + steps: STEPS.map(s => s.id), + get active() { return !!S; }, + state: () => (S ? { step: S.i, id: STEPS[S.i]?.id, phase: S.phase, goals: { ...S.goals }, skipped: [...S.skipped], clock: +S.clock.toFixed(2), reward: S.reward } : null) + }; +})();