Gabung wf/t6-hazard: bahaya arena

This commit is contained in:
rizkyaz0 2026-10-05 14:49:31 +07:00
commit 2e211b9793
5 changed files with 436 additions and 2 deletions

View File

@ -34,6 +34,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- Statistik pertandingan (RENCANA 5.5): `stats.js` (`window.MatchStats`) + `stats.css`; tag-nya ada di baris sendiri sebelum `</main>` di `index.html`. Statistik pemain 1 per pertandingan versus (juga 2 pemain, Arcade, Survival, Bayangan; latihan = `reset(false)`, tanpa statistik). Kait di game.js, semuanya satu baris dengan `window.MatchStats?.`: `reset(versus)` di `startMatch` dan `reset`; `tick(dt, match, hero.hp, dummy.damage, BALANCE.dummyMax)` di awal `advanceMatch` (damage keluar/diterima = selisih HP per langkah, durasi = jumlah waktu fase fight, ronde = peralihan fight -> ko); `cast(jenis, hero.action)` di `cast` dan `startAttack`; `hit(hitSource(), counter, comboHits, hero.action)` di `hitDummy` (juga jalur chip blok CPU) dan `hit(null, ...)` di burst `dotDamage`; `parry()`/`block()` di `heroGuard`; `finish(info)` di event 'complete' menaruh ringkasan di `info.stats` (dibawa `Meta.award` dan `FrontEnd.showResult`). Akurasi jurus = pemakaian yang kena sekali; pukulan proyektil/ultimate dikaitkan ke jurus terakhir dalam jendela waktu (`MatchStats.WINDOW`), jadi bila dua jurus bertumpuk bisa meleset satu. Layar hasil: `ResultView.view` memanggil `MatchStats.resultHtml` (tombol STATISTIK di kanan baris skor + kartu `.rs-ms` yang menutup kolom tengah di atas tombol aksi, diatur `MatchStats.mount`; Esc menutup kartu lebih dulu daripada kembali ke menu); tanpa `info.stats` (data uji) tidak ada tombol. Simpanan seumur hidup: `Meta.award` memanggil `MatchStats.commit(save.stats, r.stats)` (kunci baru `dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches`; `bestCombo` ikut dinaikkan ke kombo terbaik seluruh ronde; simpanan lama tanpa kunci terbaca 0), tampil di Koleksi lewat `MatchStats.lifetimeHtml`. Uji manual cepat: `MatchStats.snapshot()` di konsol saat pertandingan berjalan. Belum ada skenario otomatis khusus statistik (mode ringan, diuji manual). - Statistik pertandingan (RENCANA 5.5): `stats.js` (`window.MatchStats`) + `stats.css`; tag-nya ada di baris sendiri sebelum `</main>` di `index.html`. Statistik pemain 1 per pertandingan versus (juga 2 pemain, Arcade, Survival, Bayangan; latihan = `reset(false)`, tanpa statistik). Kait di game.js, semuanya satu baris dengan `window.MatchStats?.`: `reset(versus)` di `startMatch` dan `reset`; `tick(dt, match, hero.hp, dummy.damage, BALANCE.dummyMax)` di awal `advanceMatch` (damage keluar/diterima = selisih HP per langkah, durasi = jumlah waktu fase fight, ronde = peralihan fight -> ko); `cast(jenis, hero.action)` di `cast` dan `startAttack`; `hit(hitSource(), counter, comboHits, hero.action)` di `hitDummy` (juga jalur chip blok CPU) dan `hit(null, ...)` di burst `dotDamage`; `parry()`/`block()` di `heroGuard`; `finish(info)` di event 'complete' menaruh ringkasan di `info.stats` (dibawa `Meta.award` dan `FrontEnd.showResult`). Akurasi jurus = pemakaian yang kena sekali; pukulan proyektil/ultimate dikaitkan ke jurus terakhir dalam jendela waktu (`MatchStats.WINDOW`), jadi bila dua jurus bertumpuk bisa meleset satu. Layar hasil: `ResultView.view` memanggil `MatchStats.resultHtml` (tombol STATISTIK di kanan baris skor + kartu `.rs-ms` yang menutup kolom tengah di atas tombol aksi, diatur `MatchStats.mount`; Esc menutup kartu lebih dulu daripada kembali ke menu); tanpa `info.stats` (data uji) tidak ada tombol. Simpanan seumur hidup: `Meta.award` memanggil `MatchStats.commit(save.stats, r.stats)` (kunci baru `dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches`; `bestCombo` ikut dinaikkan ke kombo terbaik seluruh ronde; simpanan lama tanpa kunci terbaca 0), tampil di Koleksi lewat `MatchStats.lifetimeHtml`. Uji manual cepat: `MatchStats.snapshot()` di konsol saat pertandingan berjalan. Belum ada skenario otomatis khusus statistik (mode ringan, diuji manual).
- Kode tantangan dan kebiasaan bermain (RENCANA 7.5 dan 7.6): `challenge.js` (`window.Challenge`, `window.Habits`) + `challenge.css`; tag-nya sebelum `menu.js` di `index.html`. **Kode** = 8 karakter dari 30 simbol tanpa 0 1 I L O U (contoh `TNNB-TPZM`), isinya 24 bit (versi, variasi, aturan, tingkat, arena, lawan, pemain) + 15 bit pemeriksa yang dicampur; `Challenge.encode({player, enemy, stage, level, rule})` / `Challenge.decode(teks)` (menerima huruf kecil, spasi, tanda hubung, tautan `?kode=`, atau kalimat yang memuat kode; hasil `{ok, cfg}` atau `{ok:false, err, msg}`). Daftar karakter, arena, tingkat, dan aturan di berkas itu menentukan arti kode: **urutannya tidak boleh diubah, hanya ditambah di belakang** (karakter/arena baru yang belum didaftarkan ikut otomatis di belakang menurut urutan deklarasi). **Aturan khusus** (`RULES`, satu per kode): `blitz` Adu cepat (satu ronde, HP awal separuh), `one` Satu ronde, `fast` Lawan gesit (jalan +25%, cooldown pulih +35%), `noblock` Tanpa blok (blok dan parry mati untuk kedua sisi), `heavy` Pukulan berat (damage +50% kedua sisi); kuncinya hanya hidup di kode, tidak disimpan di localStorage (jadi tidak bentrok dengan aturan harian). **Alur**: tombol KODE TANTANGAN di pojok kiri atas menu utama (di sebelah TUTORIAL, absolut supaya tinggi menu tidak berubah) membuka dialog `#ch-dialog` yang memvalidasi sambil mengetik dan langsung memulai (`Challenge.start(cfg)` memanggil `startMatch({... mode:'versus', vs:{label}, codeRule:{rule, code}})`); `?kode=XXXXXXXX` di alamat membuka dialog yang sama sebagai undangan setelah boot (parameter dihapus dari alamat sesudahnya; `mobile.html` meneruskan query ke iframe). Layar arena (hanya versus melawan CPU) mendapat baris `ATURAN` (menggilir aturan, berlaku juga untuk LET'S FIGHT dan MAIN LAGI lewat `Challenge.pending(mode, p2)` di `menu.js start()`; menu utama mengosongkannya) / `SALIN KODE` / `TAUTAN` di kolom kanan `.match-rules`; layar hasil mendapat `SALIN KODE` / `TAUTAN` di pojok kanan atas `.rs-head` (tidak untuk Arcade, Survival, dua pemain, atau Bayangan). Penyalinan: `navigator.clipboard`, lalu `execCommand('copy')`, lalu `prompt()`. **Kait** (semua `window.Challenge?.x?.()`, dibungkus `guard`: galat hanya dicatat di konsol, tanpa berkas ini game berjalan seperti dulu): game.js `begin(options)` di `startMatch` sesudah validasi dan sebelum `refreshPassives` (menetapkan aturan aktif, judul layar VS "TANTANGAN · ...", penanda `.ch-badge` di bawah jam HUD, dan mencatat kebiasaan), `tune(sideMods)` di akhir `refreshPassives` (aturan = pengali kunci `passiveMod`), `roundStart(hero, dummy, BALANCE)` di akhir `resetCombat` (HP awal), `roundEnd(match, event)` dan `finish(info)` di `advanceMatch` (satu ronde menetapkan `match.winner` sebelum efek K.O. akhir; `finish` mengingat pengaturan untuk SALIN KODE dan mematikan `info.perfect` pada aturan satu ronde supaya bonus/pencapaian BERSIH tidak murah), `hud(match)` di `updateMatchHud` (teks "SATU RONDE"), `noBlock` di `press` (tombol L) dan `heroGuard`; menu.js `decorate(root, screen, {player, enemy, stage, level, mode, p2, runKind, result})` di akhir `render()`. `startMatch` mengabaikan `codeRule` untuk latihan, dua pemain, dan Bayangan. Aturan tidak memakai pengacak, jadi hasil pertandingan tetap deterministik. **Kebiasaan bermain** (`Habits`): `localStorage['konoha.habits.v1']` = `{v:1, n, ch, mode, stage, foe}` (hitungan per karakter yang dipilih, mode, arena, dan karakter lawan; tanpa waktu dan identitas), dicatat di `begin()` tiap pertandingan dimulai (latihan juga; Bayangan tidak dihitung sebagai lawan; mode dikenali dari `options.mode`, `vs.p2`, awalan label ARCADE/SURVIVAL, `codeRule`, atau `options.kind` untuk mode baru). Terpisah dari `Meta.save`, jadi simpanan lama tak tersentuh dan data rusak dibaca sebagai nol. Bagian "Kebiasaan bermainmu" (karakter, mode, arena favorit, lawan paling sering, tombol HAPUS STATISTIK) disisipkan ke tab Koleksi oleh `MutationObserver` pada `#meta-overlay` (sebelum daftar pencapaian; `meta.js` tidak diubah); catatan privasi satu baris di layar About lewat `decorate`. Belum ada skenario otomatis dan UI belum diperiksa di peramban oleh penulisnya (hanya `node --check`, smoke, dan pemeriksaan penyandi sekali pakai: 5.880 kombinasi bolak-balik tanpa tabrakan): uji manual di 1024x520 dan 844x390. `precache.js`/`files.json` perlu dibangun ulang agar `challenge.js`/`challenge.css` ikut tersimpan offline. Konsol: `Challenge.state()`, `Challenge.open()`, `Habits.data()`. - Kode tantangan dan kebiasaan bermain (RENCANA 7.5 dan 7.6): `challenge.js` (`window.Challenge`, `window.Habits`) + `challenge.css`; tag-nya sebelum `menu.js` di `index.html`. **Kode** = 8 karakter dari 30 simbol tanpa 0 1 I L O U (contoh `TNNB-TPZM`), isinya 24 bit (versi, variasi, aturan, tingkat, arena, lawan, pemain) + 15 bit pemeriksa yang dicampur; `Challenge.encode({player, enemy, stage, level, rule})` / `Challenge.decode(teks)` (menerima huruf kecil, spasi, tanda hubung, tautan `?kode=`, atau kalimat yang memuat kode; hasil `{ok, cfg}` atau `{ok:false, err, msg}`). Daftar karakter, arena, tingkat, dan aturan di berkas itu menentukan arti kode: **urutannya tidak boleh diubah, hanya ditambah di belakang** (karakter/arena baru yang belum didaftarkan ikut otomatis di belakang menurut urutan deklarasi). **Aturan khusus** (`RULES`, satu per kode): `blitz` Adu cepat (satu ronde, HP awal separuh), `one` Satu ronde, `fast` Lawan gesit (jalan +25%, cooldown pulih +35%), `noblock` Tanpa blok (blok dan parry mati untuk kedua sisi), `heavy` Pukulan berat (damage +50% kedua sisi); kuncinya hanya hidup di kode, tidak disimpan di localStorage (jadi tidak bentrok dengan aturan harian). **Alur**: tombol KODE TANTANGAN di pojok kiri atas menu utama (di sebelah TUTORIAL, absolut supaya tinggi menu tidak berubah) membuka dialog `#ch-dialog` yang memvalidasi sambil mengetik dan langsung memulai (`Challenge.start(cfg)` memanggil `startMatch({... mode:'versus', vs:{label}, codeRule:{rule, code}})`); `?kode=XXXXXXXX` di alamat membuka dialog yang sama sebagai undangan setelah boot (parameter dihapus dari alamat sesudahnya; `mobile.html` meneruskan query ke iframe). Layar arena (hanya versus melawan CPU) mendapat baris `ATURAN` (menggilir aturan, berlaku juga untuk LET'S FIGHT dan MAIN LAGI lewat `Challenge.pending(mode, p2)` di `menu.js start()`; menu utama mengosongkannya) / `SALIN KODE` / `TAUTAN` di kolom kanan `.match-rules`; layar hasil mendapat `SALIN KODE` / `TAUTAN` di pojok kanan atas `.rs-head` (tidak untuk Arcade, Survival, dua pemain, atau Bayangan). Penyalinan: `navigator.clipboard`, lalu `execCommand('copy')`, lalu `prompt()`. **Kait** (semua `window.Challenge?.x?.()`, dibungkus `guard`: galat hanya dicatat di konsol, tanpa berkas ini game berjalan seperti dulu): game.js `begin(options)` di `startMatch` sesudah validasi dan sebelum `refreshPassives` (menetapkan aturan aktif, judul layar VS "TANTANGAN · ...", penanda `.ch-badge` di bawah jam HUD, dan mencatat kebiasaan), `tune(sideMods)` di akhir `refreshPassives` (aturan = pengali kunci `passiveMod`), `roundStart(hero, dummy, BALANCE)` di akhir `resetCombat` (HP awal), `roundEnd(match, event)` dan `finish(info)` di `advanceMatch` (satu ronde menetapkan `match.winner` sebelum efek K.O. akhir; `finish` mengingat pengaturan untuk SALIN KODE dan mematikan `info.perfect` pada aturan satu ronde supaya bonus/pencapaian BERSIH tidak murah), `hud(match)` di `updateMatchHud` (teks "SATU RONDE"), `noBlock` di `press` (tombol L) dan `heroGuard`; menu.js `decorate(root, screen, {player, enemy, stage, level, mode, p2, runKind, result})` di akhir `render()`. `startMatch` mengabaikan `codeRule` untuk latihan, dua pemain, dan Bayangan. Aturan tidak memakai pengacak, jadi hasil pertandingan tetap deterministik. **Kebiasaan bermain** (`Habits`): `localStorage['konoha.habits.v1']` = `{v:1, n, ch, mode, stage, foe}` (hitungan per karakter yang dipilih, mode, arena, dan karakter lawan; tanpa waktu dan identitas), dicatat di `begin()` tiap pertandingan dimulai (latihan juga; Bayangan tidak dihitung sebagai lawan; mode dikenali dari `options.mode`, `vs.p2`, awalan label ARCADE/SURVIVAL, `codeRule`, atau `options.kind` untuk mode baru). Terpisah dari `Meta.save`, jadi simpanan lama tak tersentuh dan data rusak dibaca sebagai nol. Bagian "Kebiasaan bermainmu" (karakter, mode, arena favorit, lawan paling sering, tombol HAPUS STATISTIK) disisipkan ke tab Koleksi oleh `MutationObserver` pada `#meta-overlay` (sebelum daftar pencapaian; `meta.js` tidak diubah); catatan privasi satu baris di layar About lewat `decorate`. Belum ada skenario otomatis dan UI belum diperiksa di peramban oleh penulisnya (hanya `node --check`, smoke, dan pemeriksaan penyandi sekali pakai: 5.880 kombinasi bolak-balik tanpa tabrakan): uji manual di 1024x520 dan 844x390. `precache.js`/`files.json` perlu dibangun ulang agar `challenge.js`/`challenge.css` ikut tersimpan offline. Konsol: `Challenge.state()`, `Challenge.open()`, `Habits.data()`.
- `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter. - `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter.
- Bahaya arena (RENCANA 6.2): `hazard.js` (`window.Hazard`) + `hazard.css`, dimuat tepat sesudah `game.js`. Satu kejutan per arena (tabel `DEFS`; arena tanpa entri tidak punya bahaya): bellora = genting jatuh (6 damage), sunspire = silau matahari (layar memutih, tanpa damage; CPU ragu 0,55 dtk), harbor = ombak dari kiri yang mendorong ke kanan sampai batas bertanda (3 damage, lompat atau berdiri di kanan garis batas untuk menghindar), elderwood = dua akar menyembul (5 damage), moonrise = petir arkana (7 damage). Tiap bahaya diberi aba-aba 1 detik (cincin/garis di lantai, kilau di langit, suara `sound()`, dan teks "Awas: ..." di baris kecil `.hz-tag` dalam `.stage-sign`; saat tenang barisnya "Bahaya: <nama>"). Aturan: hanya `match.mode === 'versus'` fase fight (latihan dan tutorial tidak); bahaya pertama 6-12 dtk sesudah ronde mulai, berikutnya 9-14 dtk sesudah aba-aba sebelumnya; tidak mulai di 4 dtk pertama, saat ultimate/sinematik (`hazardCtx.busy`), saat salah satu petarung jatuh, atau di 3,5 dtk terakhir ronde. Damage dikali pasif `dmgIn` sisi yang kena, tidak pernah mematikan (menyisakan 1 HP), tanpa luka/knockback, tidak menambah kombo, dan melewati petarung yang sedang kebal (`invuln`); zona = jarak datar ke pusat tanda (sama dengan cincin yang tergambar) dan tinggi kaki di atas lantai (70, petir 100), jadi lompat atau geser menghindar. Pemain, CPU, dan P2 kena aturan yang sama; CPU mencoba keluar dari zona lewat rencana `sidestep` bawaan (peluang = `evade` tingkat, jeda = `reaction`; hanya bahaya berzona, bukan ombak). Deterministik: pengacak LCG sendiri berbenih (benih mesin saat ronde mulai + nomor ronde + arena) dan waktu = `dt` simulasi; pengacak kosmetik terpisah, dan "Kurangi gerakan" (`Access.motion`) hanya mengubah efek (tanpa getar dan kilat putih, silau jadi semburat redup, serpihan sedikit), bukan jalannya pertandingan. Kait di game.js: `Hazard.step(dt)` di akhir `step()` (sebelum `feelWatchKo`), `Hazard.draw(ctx, 'back'|'front')` di `draw()` (tanda lantai sebelum petarung, efek sebelum `drawSquadron`/`drawEffects`), dan `__game.hazardCtx` (getter `match`, `stage`, `time`, `seed`, `menu`, `busy`, `cpu`; `sound`; `cpuRead()`; `cpuDodge(dir, dur)`). Semburat layar memakai elemen DOM `#hz-glare` (di atas kanvas, di bawah HUD). Dimatikan lewat Pengaturan ("Bahaya arena", bawaan Aktif, `localStorage['konoha.hazard']`) atau `Hazard.enabled = false` (sementara, tidak disimpan; untuk tantangan khusus); keduanya harus menyala. Uji manual: `?hazard=cepat` di URL (bahaya tiap 3-4,5 dtk), `Hazard.trigger()` di konsol saat versus berjalan, `Hazard.state()`, `Hazard.onEvent(fn)` (jenis `aba-aba` dan `kena`). Catatan: statistik pertandingan menghitung damage dari selisih HP, jadi damage bahaya ikut tercatat sebagai diterima pemain atau dikeluarkan pemain; simulator keseimbangan (`tests/bot.mjs`, arena Bellora) ikut memainkan bahaya, set `Hazard.enabled = false` di sana bila ingin angka tanpa bahaya. Belum ada skenario otomatis dan belum dilihat di peramban oleh penulisnya (hanya `node --check` dan smoke).
- `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang. - `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang.
## API uji: `window.__game` ## API uji: `window.__game`

14
game.js
View File

@ -1881,6 +1881,7 @@
updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar);updateBrowo(dt);updateCf(dt); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake); updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar);updateBrowo(dt);updateCf(dt); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake);
if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'browo' || CF[voiceKind] || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt; if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'browo' || CF[voiceKind] || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt;
if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice(); if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice();
window.Hazard?.step(dt); // bahaya arena (hazard.js): hanya fase fight versus, waktu simulasi
feelWatchKo(); advanceMatch(realDt); feelWatchKo(); advanceMatch(realDt);
} }
// MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them. // MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them.
@ -2786,8 +2787,10 @@
// Light motes provide life without moving the fixed stage or camera. // Light motes provide life without moving the fixed stage or camera.
for (let i = 0; i < 14; i++) { const x = (i * 117 + realTime * (6 + i % 3)) % W, y = 210 + (i * 47) % 330 + Math.sin(realTime + i) * 9; ctx.fillStyle = i % 2 ? '#fff8cb88' : '#f0ffe94d'; ctx.fillRect(Math.round(x), Math.round(y), 2, 2); } for (let i = 0; i < 14; i++) { const x = (i * 117 + realTime * (6 + i % 3)) % W, y = 210 + (i * 47) % 330 + Math.sin(realTime + i) * 9; ctx.fillStyle = i % 2 ? '#fff8cb88' : '#f0ffe94d'; ctx.fillRect(Math.round(x), Math.round(y), 2, 2); }
drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); drawCfBack(); drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); drawCfBack();
window.Hazard?.draw(ctx, 'back'); // bahaya arena: tanda di lantai, di belakang petarung
if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); }
drawBrowoGiant(true); drawCfFront(); drawBrowoGiant(true); drawCfFront();
window.Hazard?.draw(ctx, 'front'); // bahaya arena: genting, akar, ombak, petir, serpihan (efek dan angka damage mesin digambar di atasnya)
drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawBrowoExtras(); drawHitboxes(); ctx.restore(); if (camera) ctx.restore(); Feel.ko.drawOverlay(ctx, viewW, viewH); updateHud(); drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawBrowoExtras(); drawHitboxes(); ctx.restore(); if (camera) ctx.restore(); Feel.ko.drawOverlay(ctx, viewW, viewH); updateHud();
} }
function fighterInfo(id,form) { function fighterInfo(id,form) {
@ -3017,6 +3020,17 @@
return true; return true;
} }
}); });
// Bahaya arena (RENCANA 6.2, hazard.js): jendela baca kecil ke keadaan mesin. hazard.js menulis damage sendiri lewat hero/dummy yang sudah terbuka di __game (tanpa pasif lawan, tanpa K.O.);
// di sini hanya status, suara, dan satu dorongan ke rencana 'sidestep' CPU (sama dengan cara CPU menghindar dari ripple NAJA dan lontaran HALDOR).
window.__game.hazardCtx = {
get match() { return match; }, get stage() { return stageId; }, get time() { return time; }, get seed() { return seed; }, get menu() { return menuOpen; },
get cpu() { return aiEnabled && !p2Mode && match?.mode === 'versus'; },
// Ultimate atau sinematik berjalan, atau layar VS menahan ronde: hazard.js tidak memulai bahaya baru.
get busy() { return cinematic > 0 || vsHold || bwx.zoom > 1.01 || bwx.dim > .004 || !!(fenrCutin || squad || enemySquad || parade || enemyParade || flock || enemyFlock || serpent || enemySerpent || quake || enemyQuake || finale || enemyFinale || skyfall || enemySkyfall || orrery || enemyOrrery || sunroar || enemySunroar || delivery || enemyDelivery || tortoise || enemyTortoise); },
sound,
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; }
};
if(F)reset(); if(F)reset();
if(window.FRONTEND_ENABLED)setMenuOpen(true); if(window.FRONTEND_ENABLED)setMenuOpen(true);
resize(); requestAnimationFrame(tick); resize(); requestAnimationFrame(tick);

14
hazard.css Normal file
View File

@ -0,0 +1,14 @@
/* Bahaya arena (RENCANA 6.2): tanda kecil di papan nama arena dan semburat layar (silau, kilat petir). Logika di hazard.js. */
/* Papan nama arena (.stage-sign, bawah tengah): satu baris kecil di bawah garis emas. Teks "Bahaya: ..." saat tenang, "Awas: ..." selama aba-aba. */
.stage-sign .hz-tag{display:block;margin-top:5px;font-size:9px;font-weight:700;letter-spacing:2.6px;text-transform:uppercase;white-space:nowrap;color:#f1b868;text-shadow:0 2px 3px #122b34}
.stage-sign .hz-tag[hidden]{display:none}
.stage-sign .hz-tag::before{content:'';display:inline-block;width:8px;height:7px;margin-right:6px;vertical-align:0;background:currentColor;clip-path:polygon(50% 0,100% 100%,0 100%)}
.stage-sign .hz-tag.warn{color:#ffe08a;text-shadow:0 0 6px #ff9a3c,0 2px 3px #122b34;animation:hzPulse .5s steps(2,end) infinite}
@keyframes hzPulse{50%{opacity:.45}}
html.acc-calm .stage-sign .hz-tag.warn{animation:none}
@media (prefers-reduced-motion:reduce){.stage-sign .hz-tag.warn{animation:none}}
/* Jendela sempit: style.css menyembunyikan papan nama arena (<=950px); di sana hanya baris bahaya yang ditampilkan, supaya penanda tetap ada. */
@media(max-width:950px){.stage-sign.hz-live{display:block}.stage-sign.hz-live>span,.stage-sign.hz-live>small:not(.hz-tag),.stage-sign.hz-live>i{display:none}.stage-sign.hz-live .hz-tag{margin-top:0}}
@media(max-width:620px){.stage-sign .hz-tag{font-size:8px;letter-spacing:1.4px}}
/* Semburat layar: di atas kanvas, di bawah HUD (z-index 2 ke atas), jadi HUD tetap terbaca saat layar memutih. Opasitas diatur hazard.js tiap bingkai. */
#hz-glare{position:absolute;inset:0;z-index:1;pointer-events:none;opacity:0;visibility:hidden;background:#fffaeb}

405
hazard.js Normal file
View File

@ -0,0 +1,405 @@
/* Bahaya arena (RENCANA 6.2): satu kejutan khas per arena, selalu dengan aba-aba 1 detik dan damage kecil yang adil.
Modul mandiri (hazard.js + hazard.css). game.js hanya memanggil window.Hazard?. di tiga tempat dan membuka satu jendela baca kecil (__game.hazardCtx):
step(dt) di akhir step() fase fight (sebelum feelWatchKo/advanceMatch); dt = waktu simulasi, jadi membeku saat hit-stop, jeda, atau menu
draw(c, 'back') sesudah drawCfBack: tanda di lantai, di belakang petarung
draw(c, 'front') sebelum drawSquadron/drawEffects: genting, akar, ombak, petir, serpihan (angka damage mesin tetap di atasnya)
Arena: bellora = genting jatuh dari menara; sunspire = silau matahari (layar memutih, tanpa damage); harbor = ombak yang menyapu lantai dan mendorong;
elderwood = akar menyembul; moonrise = petir arkana. Arena lain (tanpa entri di DEFS) tidak punya bahaya.
Aturan:
- Hanya mode versus (Arcade, Survival, dua pemain, Bayangan ikut); latihan dan tutorial tidak. Dimatikan lewat Pengaturan ("Bahaya arena", bawaan Aktif, disimpan di
localStorage['konoha.hazard']) atau lewat window.Hazard.enabled = false (sementara, tidak disimpan; untuk tantangan khusus). Keduanya harus menyala agar bahaya jalan.
- Deterministik: jadwal dan posisi memakai pengacak berbenih sendiri (benih = benih mesin saat ronde mulai + nomor ronde + arena) dan waktu simulasi (dt);
tanpa Math.random/Date.now. Pengacak kosmetik (partikel) terpisah dan jumlah efek bergantung "Kurangi gerakan", jadi pilihan itu tidak mengubah jalannya pertandingan.
- Simetris: pemain, CPU, dan P2 kena aturan yang sama (jarak datar ke tanda, tinggi kaki di atas lantai, tidak sedang kebal). Damage tetap kecil (maks 7), dikali pasif
dmgIn sisi yang kena, tidak pernah mematikan (menyisakan 1 HP), tanpa luka/knockback, tidak menambah kombo, dan tidak menyentuh pcarry/Bayangan. Bahaya tidak mulai
di 4 detik pertama ronde, saat ultimate/sinematik berjalan, saat salah satu petarung jatuh, atau di 3,5 detik terakhir ronde.
- CPU mencoba keluar dari zona bertanda lewat rencana 'sidestep' bawaan (peluang = evade tingkat, jeda = reaction), seperti ripple NAJA. P2 manusia tidak dibantu.
- Hormati "Kurangi gerakan" (window.Access.motion): tanpa getar, serpihan sedikit, silau hanya semburat redup, tanpa denyut.
Uji manual: ?hazard=cepat di URL (bahaya tiap 3-4,5 dtk), atau Hazard.trigger() di konsol saat pertandingan versus berjalan; Hazard.state() meringkas keadaan. */
(() => {
'use strict';
const W = 1280, TELE = 1, FLOOR = .26; // lebar dunia, lama aba-aba (detik), perspektif lantai (rasio sumbu tegak elips)
const PREF = 'konoha.hazard', FAST = /[?&]hazard=cepat\b/.test(location.search);
// Semua teks untuk pemain ada di sini (siap diterjemahkan nanti, RENCANA 4.4).
const TXT = { tag: 'Bahaya', warn: 'Awas', label: 'Bahaya arena', hint: 'Kejutan khas tiap arena saat melawan CPU atau dua pemain, selalu ada tanda 1 detik sebelumnya.', on: 'Aktif', off: 'Mati' };
const DEFS = {
bellora: { kind: 'tile', name: 'Lonceng menara', warn: 'genting jatuh' },
sunspire: { kind: 'glare', name: 'Silau matahari', warn: 'silau' },
harbor: { kind: 'wave', name: 'Ombak pasang', warn: 'ombak dari kiri' },
elderwood: { kind: 'root', name: 'Akar menyembul', warn: 'akar menyembul' },
moonrise: { kind: 'bolt', name: 'Petir arkana', warn: 'petir arkana' }
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const ease = u => 1 - Math.pow(1 - clamp(u, 0, 1), 3);
const lcg = s => () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296; // pengacak mesin (LCG), tetapi terpisah dari random() mesin
const hash = n => { n = Math.imul(n ^ (n >>> 15), 2246822519); n = Math.imul(n ^ (n >>> 13), 3266489917); return ((n ^ (n >>> 16)) >>> 0) / 4294967296; };
const strHash = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return h >>> 0; };
const game = () => window.__game, ctxOf = () => window.__game && window.__game.hazardCtx;
const motion = () => { try { return window.Access ? window.Access.motion : 1; } catch (_) { return 1; } }; // 1 = penuh, 0 = gerak dikurangi
const WHO = ['hero', 'dummy'];
const alive = (g, who) => who === 'hero' ? g.hero.hp > 0 : !g.dummy.ko && g.dummy.state !== 'down' && g.dummy.state !== 'recover';
const feet = (g, f) => g.config.groundY - f.y; // tinggi kaki di atas lantai
const snd = (x, type) => { try { x.sound(type); } catch (_) {} };
const kick = (g, dir, amp) => { if (motion() > 0) try { g.feel.kickAt(dir, amp); } catch (_) {} };
const ell = (c, x, y, rx, ry) => c.ellipse(x, y, Math.max(0, rx), Math.max(0, ry), 0, 0, 6.2832);
const sv = (c, fn) => { c.save(); try { fn(); } finally { c.restore(); } }; // save/restore yang tetap seimbang walau fn melempar galat
let userOn = true, runtime = true, dead = false, st = null, tagEl = null, selEl = null, glareEl = null, tagKey = '', glareKey = '';
const listeners = [];
try { userOn = localStorage.getItem(PREF) !== '0'; } catch (_) {}
const on = () => userOn && runtime && !dead;
// ---- Keadaan ronde ----
// st = { match, stage, def, seen (phaseTime terakhir), clock, ft (waktu fight), calm (detik tanpa kesibukan), next (jadwal bahaya berikut), rnd (gameplay), fx (kosmetik), hz[], parts[], texts[] }
function begin(x, m, def) {
const s = ((x.seed | 0) ^ Math.imul(m.round + 1, 2654435761) ^ strHash(x.stage)) >>> 0, rnd = lcg(s || 1);
st = { match: m, stage: x.stage, def, seen: m.phaseTime, clock: 0, ft: 0, calm: 0, next: 0, rnd, fx: lcg((s ^ 2654435769) >>> 0 || 7), hz: [], parts: [], texts: [] };
st.next = FAST ? 4.5 : 6 + rnd() * 6; // bahaya pertama 6-12 dtk sesudah ronde mulai (tidak pernah di 4 dtk pertama), berikutnya 9-14 dtk sesudah aba-aba sebelumnya
}
const fresh = (x, m) => st && st.match === m && st.stage === x.stage && m.phaseTime >= st.seen - 1e-6;
// Keadaan untuk bingkai ini, atau null bila bukan ronde fight versus yang sudah disentuh step() (mis. satu bingkai pertama sesudah intro).
function current() {
const x = ctxOf(); if (!x || !on()) return null;
const m = x.match; return m && m.mode === 'versus' && m.phase === 'fight' && fresh(x, m) ? st : null;
}
function wipe() { if (st) { st.hz.length = st.parts.length = st.texts.length = 0; } }
function fail(e) { dead = true; try { console.error('Bahaya arena dimatikan karena galat:', e); } catch (_) {} wipe(); syncTag(); syncOverlay(null); }
function emit(type, h) { for (const fn of listeners) try { fn({ type, kind: h.kind, x: h.x ?? null, stage: st && st.stage }); } catch (e) { console.error(e); } }
// ---- Efek kosmetik (pengacak st.fx, batas jumlah) ----
function part(x, y, vx, vy, life, size, color, g = 760, kind) { if (st.parts.length < 90) st.parts.push({ x, y, vx, vy, life, max: life, size, color, g, kind }); }
function burst(x, y, color, n, power, o = {}) {
n = motion() > 0 ? n : Math.ceil(n / 3);
for (let i = 0; i < n; i++) { const a = st.fx() * 6.2832, sp = (.35 + st.fx() * .65) * power; part(x, y, Math.cos(a) * sp, Math.sin(a) * sp - (o.up ?? 90), .45 + st.fx() * .5, 2 + st.fx() * 4, color, o.g ?? 760, o.kind); }
}
function floatText(x, y, txt, color) { if (st.texts.length < 4) st.texts.push({ x, y, txt, color, life: .9, max: .9 }); }
function tickFx(dt, g) {
const fl = g.config.groundY + 6;
for (let i = st.parts.length - 1; i >= 0; i--) {
const p = st.parts[i]; p.life -= dt; if (p.life <= 0) { st.parts.splice(i, 1); continue; }
p.vy += p.g * dt; p.x += p.vx * dt; p.y += p.vy * dt; if (p.g > 0 && p.y > fl) { p.y = fl; p.vy = 0; p.vx *= .5; }
}
for (let i = st.texts.length - 1; i >= 0; i--) { const t = st.texts[i]; t.life -= dt; t.y -= 46 * dt; if (t.life <= 0) st.texts.splice(i, 1); }
}
function drawFx(c, s) {
for (const p of s.parts) {
const k = clamp(p.life / p.max, 0, 1), dust = p.kind === 'dust', sz = dust ? p.size * (2.4 - k) : p.size;
c.globalAlpha = (dust ? .5 : 1) * Math.min(1, k * 1.8); c.fillStyle = p.color; c.fillRect(Math.round(p.x - sz / 2), Math.round(p.y - sz / 2), Math.ceil(sz), Math.ceil(sz));
}
c.globalAlpha = 1; c.textAlign = 'center'; c.lineJoin = 'round'; c.font = '900 26px Rajdhani, sans-serif'; c.lineWidth = 5; c.strokeStyle = '#254247';
for (const t of s.texts) { c.globalAlpha = Math.min(1, t.life / t.max * 2.5); c.strokeText(t.txt, t.x, t.y); c.fillStyle = t.color; c.fillText(t.txt, t.x, t.y); }
c.globalAlpha = 1;
}
// ---- Damage: satu pintu untuk pemain, CPU, dan P2 ----
function hurt(g, x, who, base, color) {
const f = g[who]; if (!alive(g, who) || f.invuln > 0) return false;
let n = Math.max(1, Math.round(base * (g.passiveMod ? g.passiveMod(f, 'dmgIn', 1) : 1)));
n = Math.min(n, Math.max(0, who === 'hero' ? f.hp - 1 : g.balance.dummyMax - 1 - f.damage)); // tidak pernah mematikan: sisa 1 HP
if (n <= 0) return false;
if (who === 'hero') f.hp -= n; else f.damage += n;
f.lastHurt = x.time; f.flash = f.flashMax = .12;
floatText(f.x, f.y - 150, '-' + n, g.feel?.table?.colors?.hurt || '#ff9b8a'); burst(f.x, f.y - 70, color, 8, 220);
return true;
}
// Zona datar per hazard: { x, r, hh } = pusat, setengah lebar tanda (sama dengan cincin yang tergambar), tinggi kaki maksimum yang masih kena.
function pickX(g, margin, pad) {
const a = g.hero.x, b = g.dummy.x, lo = Math.min(a, b) - pad, hi = Math.max(a, b) + pad;
return clamp(lo + st.rnd() * (hi - lo), margin, W - margin);
}
function safeX(zs, fx) { // titik aman terdekat di luar semua zona (null bila tidak ada)
let best = null;
for (const z of zs) for (const s of [-1, 1]) {
const c = z.x + s * (z.r + 26); if (c < 130 || c > W - 130 || zs.some(o => Math.abs(c - o.x) < o.r + 10)) continue;
if (best === null || Math.abs(c - fx) < Math.abs(best - fx)) best = c;
}
return best;
}
function armCpu(h, x) { // satu undian selalu dipakai, supaya urutan pengacak tidak bergantung pada mode
const roll = st.rnd();
if (x.cpu && x.cpuRead && x.cpuDodge) { const P = x.cpuRead(); h.cpuAt = P.reaction + .06; h.cpuDo = roll < P.evade; }
}
// Kembali true tepat sekali, pada saat serangan jatuh (t = TELE), sesudah zona dinilai untuk pemain dan lawan sekaligus.
function zoneStep(h, g, x) {
if (h.cpuDo && !h.cpuDone && h.t >= h.cpuAt && h.t < TELE - .3) {
const f = g.dummy, z = h.zones.reduce((a, b) => Math.abs(f.x - a.x) <= Math.abs(f.x - b.x) ? a : b);
if (Math.abs(f.x - z.x) >= z.r + 10) h.cpuDone = true;
else { const to = safeX(h.zones, f.x); if (to === null || x.cpuDodge(Math.sign(to - f.x) || 1, Math.min(TELE - h.t, Math.abs(to - f.x) / 380 + .06))) h.cpuDone = true; }
}
if (h.struck || h.t < TELE) return false;
h.struck = true;
for (const who of WHO) { const f = g[who]; if (h.zones.some(z => Math.abs(f.x - z.x) < z.r && feet(g, f) < z.hh)) hurt(g, x, who, h.dmg, h.col); }
return true;
}
// ---- Gambar bersama ----
function ring(c, x, y, r, col, a, dash, off) { // cincin tanda di lantai: garis gelap di bawah garis berwarna supaya terbaca di lantai terang maupun gelap
c.setLineDash(dash || []); c.lineDashOffset = off || 0;
c.lineWidth = 5; c.strokeStyle = 'rgba(16,12,8,' + (.42 * a).toFixed(3) + ')'; c.beginPath(); ell(c, x, y, r, r * FLOOR); c.stroke();
c.lineWidth = 2.5; c.strokeStyle = col; c.globalAlpha = clamp(a, 0, 1); c.beginPath(); ell(c, x, y, r, r * FLOOR); c.stroke();
c.globalAlpha = 1; c.setLineDash([]); c.lineDashOffset = 0;
}
function shock(c, x, y, u, r1, col) { // cincin kejut melebar di lantai, u 0..1
if (u <= 0 || u >= 1) return;
const r = 20 + (r1 - 20) * ease(u); c.globalAlpha = (1 - u) * .7; c.strokeStyle = col; c.lineWidth = 5 * (1 - u) + 1; c.beginPath(); ell(c, x, y, r, r * FLOOR); c.stroke(); c.globalAlpha = 1;
}
const pulse = () => motion() > 0 ? .5 + .5 * Math.sin(st.clock * 16) : 1;
const CRACK = [[-2.7, 1], [-1.9, .8], [-1, .95], [.1, .85], [.9, 1], [1.8, .75], [2.7, .9]]; // sudut (rad) dan panjang relatif retakan
function cracks(c, x, y, r, k, col) {
c.strokeStyle = col; c.lineWidth = 2;
for (const [a, l] of CRACK) { const L = l * r * k; c.beginPath(); c.moveTo(x, y); c.lineTo(x + Math.cos(a) * L * .55 + 4, y + Math.sin(a) * L * .55 * FLOOR); c.lineTo(x + Math.cos(a) * L, y + Math.sin(a) * L * FLOOR); c.stroke(); }
}
function tilePoly(c, x, y, a) { // genting terakota berputar, digambar tanpa transformasi
const ca = Math.cos(a), sa = Math.sin(a);
c.beginPath(); [[-17, -8], [17, -8], [17, 8], [-17, 8]].forEach(([px, py], i) => { const X = x + px * ca - py * sa, Y = y + px * sa + py * ca; if (i) c.lineTo(X, Y); else c.moveTo(X, Y); }); c.closePath();
c.fillStyle = '#c9683e'; c.fill(); c.lineWidth = 2; c.strokeStyle = '#7a361f'; c.stroke();
}
function drawRoot(c, x, G0, hgt, sway, wb) { // akar meruncing yang melengkung, pangkalnya ditutup gundukan tanah
if (hgt <= 1) return;
const n = 9, L = [], R = [];
for (let i = 0; i <= n; i++) { const u = i / n, cx = x + Math.sin(u * 3.4 + sway) * 20 * u, w = Math.max(3, wb * Math.pow(1 - u, .8)); L.push([cx - w, G0 + 10 - hgt * u]); R.push([cx + w, G0 + 10 - hgt * u]); }
c.beginPath(); c.moveTo(L[0][0], L[0][1]); for (let i = 1; i <= n; i++) c.lineTo(L[i][0], L[i][1]); for (let i = n; i >= 0; i--) c.lineTo(R[i][0], R[i][1]); c.closePath();
const gr = c.createLinearGradient(x - wb, 0, x + wb, 0); gr.addColorStop(0, '#2f1d0f'); gr.addColorStop(.5, '#6a4426'); gr.addColorStop(1, '#2f1d0f');
c.fillStyle = gr; c.fill(); c.lineWidth = 2; c.strokeStyle = '#1f1208'; c.stroke();
c.fillStyle = '#6aa33c'; for (const u of [.22, .48, .72]) { const p = L[Math.round(u * n)]; c.beginPath(); ell(c, p[0] + 3, p[1], 7 * (1 - u * .6), 3.5); c.fill(); }
c.fillStyle = '#3c2817'; c.beginPath(); ell(c, x, G0 + 9, wb + 16, 8); c.fill();
}
function boltPath(h, g, flick) { // petir bergerigi dari langit ke tanda; gerigi ditentukan hash(benih hazard), bukan Math.random
const G0 = g.config.groundY + 4, n = 14, pts = [];
for (let i = 0; i <= n; i++) { const u = i / n, j = i === n ? 0 : 1; pts.push([h.x + (hash(h.sd + i * 31 + flick * 977) - .5) * 70 * j * (1 - u * .35), -700 + (G0 + 700) * u]); }
return pts;
}
// ---- Lima bahaya. init(h, g, x) memilih tempat dan aba-aba; update(h, dt, g, x) menjalankan waktu dan serangan; back/front menggambar; flash(h) = semburat layar ----
const KINDS = {
tile: { // bellora: lonceng menara bergetar, genting jatuh ke lantai di tanda
init(h, g, x) { h.x = pickX(g, 150, 90); h.zones = [{ x: h.x, r: 84, hh: 70 }]; h.dmg = 6; h.col = '#ffbf6e'; h.end = TELE + 1.1; h.tiles = [[-44, 0], [8, .08], [50, .03]]; armCpu(h, x); snd(x, 'guard'); },
update(h, dt, g, x) {
if (!h.bell && h.t >= .5) { h.bell = 1; snd(x, 'guard'); }
if (zoneStep(h, g, x)) {
const G0 = g.config.groundY; snd(x, 'heavy'); kick(g, 1, 5); if (motion() > 0) try { g.feel.ripple(h.x, G0 - 4, 1, '#f2dcae'); } catch (_) {}
burst(h.x, G0 - 10, '#c9683e', 12, 360); burst(h.x, G0 - 6, '#d8c9a8', 10, 150, { kind: 'dust', g: -30 }); emit('kena', h);
}
},
back(c, h, g) {
const y = g.config.groundY + 4, k = clamp(h.t / TELE, 0, 1);
if (h.t < TELE) {
c.fillStyle = 'rgba(90,60,25,' + (.1 + .22 * k).toFixed(3) + ')'; c.beginPath(); ell(c, h.x, y, 84 * (.3 + .7 * k), 84 * (.3 + .7 * k) * FLOOR); c.fill(); // bayangan yang membesar
ring(c, h.x, y, 84, '#ffbf6e', .55 + .4 * pulse(), [10, 7]);
} else { c.globalAlpha = (1 - clamp((h.t - TELE) / 1.1, 0, 1)) * .8; cracks(c, h.x, y, 70, 1, 'rgba(70,50,30,.9)'); c.globalAlpha = 1; }
},
front(c, h, g) {
const G0 = g.config.groundY;
if (h.t >= TELE) { shock(c, h.x, G0 + 4, (h.t - TELE) / .4, 130, '#efe0bd'); return; }
c.fillStyle = 'rgba(222,206,172,.8)'; // debu halus rontok dari atas selama aba-aba
for (let i = 0; i < 9; i++) { const y = ((h.t * (260 + 60 * (i % 3)) + i * 97) % (G0 + 240)) - 220; if (y < G0 - 8) c.fillRect(Math.round(h.x + ((i * 53) % 120) - 60), Math.round(y), 3, 3 + (i % 3)); }
h.tiles.forEach(([dx, d], i) => { const u = clamp((h.t - (TELE - d - .55)) / .55, 0, 1); if (u > 0) tilePoly(c, h.x + dx, -200 + (G0 - 10 + 200) * u * u, i + u * 7); }); // jatuh 0,55 dtk; yang sudah mendarat diam di lantai sampai ledakan
}
},
glare: { // sunspire: matahari berkilat di langit (di bawah HUD atas, sy 175-290), lalu layar memutih sebentar (tanpa damage)
init(h, g, x) { h.sx = 240 + st.rnd() * 800; h.sy = 175 + st.rnd() * 115; h.end = TELE + 1.8; h.dmg = 0; h.zones = []; snd(x, 'cast'); },
update(h, dt, g, x) {
if (h.go || h.t < TELE) return;
h.go = 1; snd(x, 'parry'); emit('kena', h);
if (x.cpu) g.dummy.aiThink = Math.max(g.dummy.aiThink || 0, .55); // CPU ikut silau: ragu memutuskan sekitar setengah detik
},
front(c, h) {
const k = clamp(h.t / TELE, 0, 1), a = h.t < TELE ? k : clamp(1 - (h.t - TELE) / .6, 0, 1); if (a <= 0) return;
const R = 14 + 64 * k * k, rot = motion() > 0 ? st.clock * .9 : 0, gr = c.createRadialGradient(h.sx, h.sy, 0, h.sx, h.sy, R * 2.2);
gr.addColorStop(0, 'rgba(255,255,240,' + (.95 * a).toFixed(3) + ')'); gr.addColorStop(.25, 'rgba(255,236,170,' + (.55 * a).toFixed(3) + ')'); gr.addColorStop(1, 'rgba(255,220,140,0)');
c.fillStyle = gr; c.beginPath(); c.arc(h.sx, h.sy, R * 2.2, 0, 6.2832); c.fill();
c.strokeStyle = 'rgba(255,255,235,' + (.8 * a).toFixed(3) + ')'; c.lineWidth = 2.5; c.lineCap = 'round';
for (let i = 0; i < 4; i++) { const an = rot + i * Math.PI / 2, L = R * (i % 2 ? 2.4 : 3.4); c.beginPath(); c.moveTo(h.sx - Math.cos(an) * L, h.sy - Math.sin(an) * L); c.lineTo(h.sx + Math.cos(an) * L, h.sy + Math.sin(an) * L); c.stroke(); }
},
flash(h) {
const u = h.t - TELE, full = motion() > 0, peak = full ? .9 : .22, up = full ? .12 : .35;
if (u < 0) return { a: clamp((h.t - .65) / .35, 0, 1) * (full ? .1 : .04), col: '#fffaeb' }; // firasat: semburat tipis sebelum silau
return { a: peak * (u < up ? u / up : u < up + .35 ? 1 : Math.max(0, 1 - (u - up - .35) / 1)), col: '#fffaeb' };
}
},
wave: { // harbor: ombak dari laut (kiri) menyapu lantai sampai batas bertanda, mendorong ke kanan dan memberi damage kecil; lompat untuk menghindar
init(h, g, x) { h.reach = 640 + st.rnd() * 600; h.xf = -90; h.speed = 1050; h.band = 160; h.push = 470; h.dmg = 3; h.col = '#9be3f7'; h.end = TELE + 2; h.hit = {}; h.zones = []; h.acc = 0; snd(x, 'cast'); },
update(h, dt, g, x) {
const G0 = g.config.groundY;
if (h.t < TELE) { h.acc += dt; if (h.acc > .09) { h.acc -= .09; part(st.fx() * 70, G0 + 4 - st.fx() * 24, 80 + st.fx() * 160, -120 - st.fx() * 140, .5, 3, '#dff6ff', 520); } return; }
if (!h.go) { h.go = 1; snd(x, 'heavy'); emit('kena', h); }
if (h.xf >= h.reach) return;
h.xf = Math.min(h.reach, -90 + h.speed * (h.t - TELE));
for (const who of WHO) {
const f = g[who]; if (!alive(g, who) || feet(g, f) >= 80 || f.x < h.xf - h.band || f.x > h.xf + 24) continue;
f.x = clamp(f.x + h.push * dt, who === 'hero' ? 70 : 110, who === 'hero' ? W - 70 : W - 100); // didorong searah ombak selama berada di dalam pita air
if (!h.hit[who]) { h.hit[who] = 1; hurt(g, x, who, h.dmg, h.col); burst(f.x, G0 - 20, '#dff6ff', 8, 260, { up: 160 }); }
}
if (motion() > 0 && (h.n = (h.n || 0) + 1) % 2 === 0) part(h.xf - st.fx() * 40, G0 - 24 - st.fx() * 30, 60 + st.fx() * 120, -150 - st.fx() * 120, .45, 3, '#eafaff', 620);
if (h.xf >= h.reach) { h.tr = h.t; h.end = h.t + .7; burst(h.reach, G0 - 20, '#eafaff', 14, 300, { up: 200 }); snd(x, 'hit'); }
},
back(c, h, g) {
if (h.t >= TELE) return;
const G0 = g.config.groundY, k = clamp(h.t / TELE, 0, 1), moving = motion() > 0;
c.fillStyle = 'rgba(70,190,235,' + (.1 + .12 * pulse() + .08 * k).toFixed(3) + ')'; c.fillRect(0, G0 - 10, h.reach, 38); // jalur yang akan disapu
c.strokeStyle = 'rgba(255,255,255,.55)'; c.lineWidth = 2; c.setLineDash([22, 16]); c.lineDashOffset = moving ? -st.clock * 200 : 0;
for (const dy of [0, 12, 24]) { c.beginPath(); c.moveTo(0, G0 + dy - 4); c.lineTo(h.reach, G0 + dy - 4); c.stroke(); }
c.setLineDash([]); c.strokeStyle = 'rgba(255,255,255,.7)'; c.lineWidth = 3;
for (let px = moving ? (st.clock * 160) % 120 : 0; px < h.reach - 20; px += 120) { c.beginPath(); c.moveTo(px, G0 - 4); c.lineTo(px + 14, G0 + 8); c.lineTo(px, G0 + 20); c.stroke(); } // panah arah ombak
c.setLineDash([10, 8]); c.strokeStyle = 'rgba(16,40,60,.55)'; c.lineWidth = 6; c.beginPath(); c.moveTo(h.reach, G0 - 34); c.lineTo(h.reach, G0 + 30); c.stroke(); // batas jangkauan: di kanan garis ini aman
c.strokeStyle = '#f4fdff'; c.lineWidth = 3; c.beginPath(); c.moveTo(h.reach, G0 - 34); c.lineTo(h.reach, G0 + 30); c.stroke(); c.setLineDash([]);
},
front(c, h, g) {
if (h.t < TELE) return;
const G0 = g.config.groundY, fade = h.tr ? clamp(1 - (h.t - h.tr) / .7, 0, 1) : 1; if (fade <= 0) return;
const xf = h.xf, x0 = xf - h.band, n = 14, ph = st.clock * 14, top = [];
for (let i = 0; i <= n; i++) { const u = i / n; top.push([x0 + (xf - x0) * u, G0 + 6 - (8 + 54 * Math.pow(u, 1.7) + Math.sin(ph + i * 1.4) * 3 * u)]); }
c.globalAlpha = fade; c.beginPath(); c.moveTo(x0, G0 + 24); for (const p of top) c.lineTo(p[0], p[1]); c.lineTo(xf + 16, G0 - 34); c.lineTo(xf + 6, G0 + 24); c.closePath();
const gr = c.createLinearGradient(0, G0 - 60, 0, G0 + 24); gr.addColorStop(0, 'rgba(180,235,250,.82)'); gr.addColorStop(1, 'rgba(36,128,186,.62)'); c.fillStyle = gr; c.fill();
c.lineWidth = 3; c.lineJoin = 'round'; c.strokeStyle = 'rgba(255,255,255,.9)'; c.beginPath(); top.forEach((p, i) => { if (i) c.lineTo(p[0], p[1]); else c.moveTo(p[0], p[1]); }); c.lineTo(xf + 16, G0 - 34); c.stroke(); c.globalAlpha = 1; // busa di puncak
}
},
root: { // elderwood: tanah retak dan menggunduk di dua tanda, lalu akar menyembul
init(h, g, x) {
const x1 = pickX(g, 190, 80), x2 = clamp(x1 + (x1 > W / 2 ? -1 : 1) * (170 + st.rnd() * 60), 130, W - 130);
h.x = x1; h.zones = [x1, x2].map(px => ({ x: px, r: 56, hh: 70 })); h.dmg = 5; h.col = '#b6e86a'; h.end = TELE + 1; h.acc = 0; armCpu(h, x); snd(x, 'step');
},
update(h, dt, g, x) {
const G0 = g.config.groundY;
if (!h.rumble && h.t >= .5) { h.rumble = 1; snd(x, 'step'); }
if (h.t < TELE) { h.acc += dt; if (h.acc > .1) { h.acc -= .1; for (const z of h.zones) part(z.x + (st.fx() - .5) * 60, G0 + 2, (st.fx() - .5) * 60, -90 - st.fx() * 90, .45, 3, '#6b4a2b', 700); } }
if (zoneStep(h, g, x)) {
snd(x, 'heavy'); kick(g, 1, 4);
for (const z of h.zones) { burst(z.x, G0, '#5a3b22', 9, 300); burst(z.x, G0 - 40, '#8fcf55', 6, 200, { g: 300 }); }
emit('kena', h);
}
},
back(c, h, g) {
const y = g.config.groundY + 4, k = clamp(h.t / TELE, 0, 1), u = clamp((h.t - TELE) / 1, 0, 1);
for (const z of h.zones) {
if (h.t < TELE) {
c.fillStyle = 'rgba(48,30,14,' + (.18 + .3 * k).toFixed(3) + ')'; c.beginPath(); ell(c, z.x, y, z.r * (.45 + .55 * k), z.r * (.45 + .55 * k) * FLOOR); c.fill();
cracks(c, z.x, y, z.r, k, 'rgba(30,18,8,.85)');
c.fillStyle = '#5b3d24'; c.beginPath(); c.ellipse(z.x, y + 3, 20 + 8 * k, 3 + 9 * k * k, 0, Math.PI, 0); c.fill(); // gundukan tanah (setengah elips) makin tinggi
ring(c, z.x, y, z.r, '#b6e86a', .5 + .4 * pulse(), [9, 7]);
} else { c.globalAlpha = (1 - u) * .8; cracks(c, z.x, y, z.r, 1, 'rgba(30,18,8,.85)'); c.globalAlpha = 1; }
}
},
front(c, h, g) {
const u = h.t - TELE; if (u < 0) return;
const hgt = 150 * ease(u / .13) * (1 - (u > .5 ? ease((u - .5) / .4) : 0)) * (u < .3 ? 1 + .06 * Math.sin(u * 30) : 1);
h.zones.forEach((z, i) => drawRoot(c, z.x, g.config.groundY, hgt * (i ? .85 : 1), i * 1.7 + 1, 24));
}
},
bolt: { // moonrise: lingkaran rune di lantai menyempit, lalu petir arkana menyambar
init(h, g, x) { h.x = pickX(g, 160, 100); h.zones = [{ x: h.x, r: 74, hh: 100 }]; h.dmg = 7; h.col = '#9fdcff'; h.end = TELE + 1; h.sd = (st.rnd() * 4294967295) >>> 0; armCpu(h, x); snd(x, 'cast'); },
update(h, dt, g, x) {
if (zoneStep(h, g, x)) {
const G0 = g.config.groundY; snd(x, 'heavy'); kick(g, 1, 9); if (motion() > 0) try { g.feel.ripple(h.x, G0 - 4, 1, '#bfe6ff'); } catch (_) {}
burst(h.x, G0 - 10, '#bfe6ff', 12, 380, { up: 160 }); burst(h.x, G0 - 20, '#b79bff', 8, 240); emit('kena', h);
}
},
back(c, h, g) {
const y = g.config.groundY + 4, z = h.zones[0], k = clamp(h.t / TELE, 0, 1);
if (h.t < TELE) {
const R = z.r + 26 * (1 - k), p = pulse(), spin = motion() > 0 ? st.clock : 0;
c.fillStyle = 'rgba(90,60,200,' + (.1 + .16 * k).toFixed(3) + ')'; c.beginPath(); ell(c, h.x, y, R, R * FLOOR); c.fill();
ring(c, h.x, y, R, '#9fdcff', .55 + .4 * p, [12, 8], spin * 40); ring(c, h.x, y, R * .6, '#c4a8ff', .5 + .3 * p);
c.fillStyle = '#d6ecff'; for (let i = 0; i < 6; i++) { const a = spin * 1.4 + i * Math.PI / 3; c.fillRect(Math.round(h.x + Math.cos(a) * R - 2), Math.round(y + Math.sin(a) * R * FLOOR - 2), 4, 4); } // tanda rune berputar
} else { c.fillStyle = 'rgba(20,16,40,' + (.5 * (1 - clamp(h.t - TELE, 0, 1))).toFixed(3) + ')'; c.beginPath(); ell(c, h.x, y, z.r * .7, z.r * .7 * FLOOR); c.fill(); } // bekas hangus
},
front(c, h, g) {
const G0 = g.config.groundY, u = h.t - TELE;
if (u < 0 && u > -.3) { // tiang cahaya tipis turun ke tanda tepat sebelum menyambar
const a = Math.pow((u + .3) / .3, 2), gr = c.createLinearGradient(0, -200, 0, G0); gr.addColorStop(0, 'rgba(160,220,255,0)'); gr.addColorStop(1, 'rgba(190,230,255,' + (.55 * a).toFixed(3) + ')');
c.fillStyle = gr; c.fillRect(h.x - 5, -200, 10, G0 + 200);
}
if (u >= 0 && u < .34) {
const pts = boltPath(h, g, Math.floor(u / .05)), a = 1 - u / .34; c.lineJoin = 'round'; c.lineCap = 'round';
for (const [w, col, al] of [[18, '#8a6cff', .35], [8, '#9fdcff', .8], [3, '#ffffff', 1]]) { c.globalAlpha = a * al; c.strokeStyle = col; c.lineWidth = w; c.beginPath(); pts.forEach((p, i) => { if (i) c.lineTo(p[0], p[1]); else c.moveTo(p[0], p[1]); }); c.stroke(); }
c.globalAlpha = 1;
}
shock(c, h.x, G0 + 4, u / .5, 120, '#bfe6ff');
},
flash(h) { const u = h.t - TELE; return motion() > 0 && u >= 0 && u < .2 ? { a: .3 * (1 - u / .2), col: '#dff1ff' } : null; }
}
};
// ---- Putaran per langkah ----
function spawn() {
const g = game(), x = ctxOf(), h = { kind: st.def.kind, t: 0, struck: false, zones: [] };
KINDS[h.kind].init(h, g, x);
st.hz.push(h); st.next = st.ft + (FAST ? 3 + st.rnd() * 1.5 : 9 + st.rnd() * 5); // jarak antar bahaya 9-14 dtk (cepat: 3-4,5 dtk)
emit('aba-aba', h); return h;
}
function step(dt) {
if (dead) return;
try {
const x = ctxOf(); if (!x) return;
if (!on()) { wipe(); return; }
const m = x.match, def = DEFS[x.stage]; if (!m || m.mode !== 'versus' || m.phase !== 'fight' || !def) return;
const g = game();
if (!fresh(x, m)) begin(x, m, def);
st.seen = m.phaseTime; st.clock += dt; st.ft += dt; st.calm = x.busy ? 0 : st.calm + dt;
tickFx(dt, g);
for (let i = st.hz.length - 1; i >= 0; i--) { const h = st.hz[i]; h.t += dt; KINDS[h.kind].update(h, dt, g, x); if (h.t >= h.end) st.hz.splice(i, 1); }
if (!st.hz.length && st.ft >= 4 && st.ft >= st.next && st.calm >= .6 && m.seconds >= TELE + 2.5 && alive(g, 'hero') && alive(g, 'dummy')) spawn();
} catch (e) { fail(e); }
}
function draw(c, layer) {
if (dead) return;
try {
if (layer === 'back') { const s = current(); syncTag(); syncOverlay(s); if (s) sv(c, () => { for (const h of s.hz) KINDS[h.kind].back?.(c, h, game()); }); }
else { const s = current(); if (s) sv(c, () => { for (const h of s.hz) KINDS[h.kind].front?.(c, h, game()); drawFx(c, s); }); }
} catch (e) { fail(e); }
}
// ---- HUD: papan nama arena, semburat layar, baris Pengaturan ----
function syncTag() {
if (!tagEl) return;
const x = ctxOf(), m = x && x.match, def = m && DEFS[x.stage], show = !!(on() && def && m.mode === 'versus' && !x.menu);
let text = '', warn = false;
if (show) { const s = st && st.match === m ? st : null; warn = !!(s && s.hz.some(h => h.t < TELE + .3)); text = warn ? TXT.warn + ': ' + def.warn + '!' : TXT.tag + ': ' + def.name; }
const key = show + '|' + text; if (key === tagKey) return; tagKey = key;
tagEl.hidden = !show; tagEl.textContent = text; tagEl.classList.toggle('warn', warn); tagEl.parentElement.classList.toggle('hz-live', show);
}
function syncOverlay(s) { // elemen DOM di atas kanvas dan di bawah HUD, jadi HUD tetap terbaca saat layar memutih
if (!glareEl) return;
let a = 0, col = '#fffaeb';
if (s) for (const h of s.hz) { const f = KINDS[h.kind].flash && KINDS[h.kind].flash(h); if (f && f.a > a) { a = f.a; col = f.col; } }
const key = a < .004 ? '0' : a.toFixed(3) + col; if (key === glareKey) return; glareKey = key;
glareEl.style.visibility = a < .004 ? 'hidden' : 'visible'; glareEl.style.opacity = a < .004 ? '0' : a.toFixed(3); if (a >= .004) glareEl.style.background = col;
}
function setPref(v) {
userOn = !!v; try { localStorage.setItem(PREF, userOn ? '1' : '0'); } catch (_) {}
if (selEl) selEl.value = userOn ? '1' : '0';
if (!on()) wipe(); syncTag(); syncOverlay(null);
}
function mount() {
const sign = document.querySelector('.stage-sign');
if (sign && !sign.querySelector('.hz-tag')) { tagEl = document.createElement('small'); tagEl.className = 'hz-tag'; tagEl.hidden = true; tagEl.setAttribute('aria-live', 'polite'); sign.append(tagEl); }
const canvas = document.getElementById('arena');
if (canvas && !document.getElementById('hz-glare')) { glareEl = document.createElement('div'); glareEl.id = 'hz-glare'; glareEl.setAttribute('aria-hidden', 'true'); canvas.after(glareEl); }
const panel = document.getElementById('settings-panel');
if (panel && !panel.querySelector('#hz-row')) {
const row = document.createElement('label'); row.className = 'setting-row'; row.id = 'hz-row';
row.innerHTML = '<span>' + TXT.label + '<small>' + TXT.hint + '</small></span><select id="hazard-select" aria-label="' + TXT.label + '"><option value="1">' + TXT.on + '</option><option value="0">' + TXT.off + '</option></select>';
const after = document.getElementById('hitbox-toggle')?.closest('.setting-row'); if (after) after.after(row); else (panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(row);
selEl = row.querySelector('select'); selEl.value = userOn ? '1' : '0'; selEl.addEventListener('change', () => setPref(selEl.value === '1'));
}
}
// ---- API publik ----
function trigger() { // paksa satu bahaya sekarang (uji manual); hanya di ronde fight versus yang tidak sedang punya bahaya
try {
const x = ctxOf(), m = x && x.match, def = m && DEFS[x.stage]; if (!on() || !def || m.mode !== 'versus' || m.phase !== 'fight') return false;
if (!fresh(x, m)) begin(x, m, def);
if (st.hz.length) return false;
spawn(); return true;
} catch (e) { fail(e); return false; }
}
window.Hazard = {
get enabled() { return runtime; }, set enabled(v) { runtime = !!v; if (!on()) wipe(); syncTag(); syncOverlay(null); }, // saklar sementara untuk tantangan khusus (tidak disimpan)
get setting() { return userOn; }, set setting(v) { setPref(v); }, // pilihan Pengaturan (disimpan)
get active() { const s = current(); return !!(s && s.hz.length); },
stages: Object.fromEntries(Object.entries(DEFS).map(([k, d]) => [k, d.name])),
step, draw, trigger, onEvent(fn) { if (typeof fn === 'function') listeners.push(fn); },
state: () => ({ setting: userOn, enabled: runtime, dead, stage: st ? st.stage : null, ft: st ? +st.ft.toFixed(2) : 0, next: st ? +st.next.toFixed(2) : 0, active: st ? st.hz.map(h => ({ kind: h.kind, t: +h.t.toFixed(2), x: h.x ?? null })) : [] })
};
mount();
})();

View File

@ -2,7 +2,7 @@
<html lang="id"> <html lang="id">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css"> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
<link rel="manifest" href="manifest.webmanifest"><link rel="icon" type="image/png" sizes="192x192" href="assets/pwa/icon-192.png"><link rel="apple-touch-icon" href="assets/pwa/apple-touch-icon.png"><link rel="stylesheet" href="touchlayout.css"><script src="pwa.js"></script><script src="touchlayout.js" defer></script> <link rel="manifest" href="manifest.webmanifest"><link rel="icon" type="image/png" sizes="192x192" href="assets/pwa/icon-192.png"><link rel="apple-touch-icon" href="assets/pwa/apple-touch-icon.png"><link rel="stylesheet" href="touchlayout.css"><script src="pwa.js"></script><script src="touchlayout.js" defer></script>
<link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"><link rel="stylesheet" href="mystery.css"><link rel="stylesheet" href="input.css"><link rel="stylesheet" href="access.css"><link rel="stylesheet" href="tutorial.css"><link rel="stylesheet" href="challenge.css"></head> <link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"><link rel="stylesheet" href="story.css"><link rel="stylesheet" href="mystery.css"><link rel="stylesheet" href="input.css"><link rel="stylesheet" href="access.css"><link rel="stylesheet" href="tutorial.css"><link rel="stylesheet" href="hazard.css"><link rel="stylesheet" href="challenge.css"></head>
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span><p class="boot-quote"></p></div></div><script src="content/loading.js"></script><script src="content/changelog.js"></script><script src="brand.js"></script><script src="content/story.js"></script><script src="story.js"></script><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi"> <body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span><p class="boot-quote"></p></div></div><script src="content/loading.js"></script><script src="content/changelog.js"></script><script src="brand.js"></script><script src="content/story.js"></script><script src="story.js"></script><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div> <canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
<header class="combat-header"> <header class="combat-header">
@ -29,6 +29,6 @@
<link rel="stylesheet" href="stats.css"><script src="stats.js"></script> <link rel="stylesheet" href="stats.css"><script src="stats.js"></script>
<!-- Rekam ulang K.O. dan tangkapan layar (RENCANA 7.3 dan 7.4): record.js memasang tombol kamera di HUD, baris di jeda dan Pengaturan; kait kecil di vs.js (layar hasil) --> <!-- Rekam ulang K.O. dan tangkapan layar (RENCANA 7.3 dan 7.4): record.js memasang tombol kamera di HUD, baris di jeda dan Pengaturan; kait kecil di vs.js (layar hasil) -->
<link rel="stylesheet" href="record.css"><script src="record.js"></script> <link rel="stylesheet" href="record.css"><script src="record.js"></script>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="tutorial.js"></script><script src="challenge.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html> </main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="access.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="hazard.js"></script><script src="touch.js"></script><script src="input.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="tutorial.js"></script><script src="challenge.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>