Bahaya arena (RENCANA 6.2): satu kejutan khas per arena dengan aba-aba 1 detik

Modul baru hazard.js (+ hazard.css), tanpa gambar atau pustaka baru; semua digambar prosedural di kanvas.
- Bellora: genting jatuh dari menara (6 damage). Sunspire: silau matahari, layar memutih sebentar tanpa damage.
  Harbor: ombak dari kiri yang mendorong ke kanan sampai garis batas (3 damage). Elderwood: dua akar menyembul (5 damage).
  Moonrise: petir arkana di lingkaran rune (7 damage).
- Aba-aba tepat 1 detik (cincin atau garis di lantai, kilau di langit, suara sound(), teks "Awas: ..."); baris kecil
  "Bahaya: <nama>" di papan nama arena. Dapat dihindari dengan lompat atau geser.
- Hanya versus (Arcade, Survival, dua pemain); latihan dan tutorial tidak. Pertama 6-12 dtk, lalu tiap 9-14 dtk; tidak di
  4 dtk pertama, saat ultimate atau sinematik, saat petarung jatuh, atau di 3,5 dtk terakhir ronde.
- Deterministik (pengacak LCG sendiri berbenih mesin + ronde + arena, waktu dari dt simulasi), simetris untuk pemain, CPU, dan
  P2; damage kecil, tidak pernah mematikan (sisa 1 HP), tanpa luka atau kombo. CPU mencoba keluar zona lewat rencana sidestep.
- Pengaturan "Bahaya arena" Aktif/Mati (bawaan Aktif) dan Hazard.enabled untuk tantangan khusus; hormati Kurangi gerakan.
- Kait game.js: Hazard.step di step(), Hazard.draw (back, front) di draw(), dan __game.hazardCtx. Uji manual: ?hazard=cepat
  atau Hazard.trigger().
This commit is contained in:
rizkyaz0 2026-10-05 14:44:34 +07:00
parent 3c9980bb09
commit 50dac48995
5 changed files with 436 additions and 2 deletions

View File

@ -32,6 +32,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- Mode cerita Arcade dan bos Bayangan (Tahap 3.5 dan 3.6): `content/story.js` (`window.STORY`: `intro[id]` 2-3 kalimat, `between[A][B]` 1-2 kalimat (atau fungsi), `ending[id]` 3 kalimat, `boss` = pembuka, ucapan VS/kalah/menang, kalimat akhir, gelar; `generic` cadangan dengan penampung `{name}` `{rival}` `{n}`) dan `story.js` + `story.css` (`window.Story`). `Story.show({kind: intro|between|boss|ending, player, enemy, step, total, stage, boss}, onDone)` menampilkan kartu layar penuh (`#story-screen`, z-index 45, di atas layar VS 40) dan memanggil onDone tepat satu kali: klik/ketuk/Spasi/Enter/Esc setelah 0,5 dtk, atau otomatis (kartu antar-lawan sekitar 4 dtk, kartu panjang lebih lama); selama tampil semua tombol ditahan dan `Meta` menahan toast (`Story.active`). Mengembalikan false tanpa kartu bila tidak ada teks, jadi alur tetap jalan tanpa cerita. `meta.js` Arcade: `startRun` (kartu pembuka) -> `nextFight` -> menang -> `betweenFights` (kartu antar-lawan, panggung lawan berikutnya jadi latar kartu) ... lawan terakhir kalah -> `arcadeCleared` (hadiah tamat 500 + pencapaian langsung dibayar seperti dulu) -> kartu bos -> `startBoss` -> `finishRun(true, 'win'|'lose')` -> kartu akhir -> layar hasil. Survival tidak memakai cerita. `run.boss` = null | 'pending' | 'fight'. Menang atas Bayangan: +1000 koin, pencapaian BAYANGAN (+50), gelar PENAKLUK BAYANGAN (`Meta.save.title`, tampil di kartu akhir dan plat pemenang layar hasil); kalah: Arcade tetap tamat, tanpa bonus bos. Uji manual: `?arcade=N` memendekkan Arcade menjadi N lawan. Bayangan = `startMatch({player: X, enemy: X, level: 'excellent', shadow: true})` (id sama; state `hero` dan `dummy` tetap terpisah): `setShadowFoe` mengatur `BALANCE.dummyMax` (+40%), `foeDamage` menambah 10% pada damage ke pemain (pembungkus `__game.receiveHit` di akhir game.js dan `dotDamage`), `dummyHpShown` memberi skala HP dasar untuk bar dan penentu waktu habis (`MatchRules.tick`), `drawDummy` memakai `ctx.filter` gelap + lapisan ungu `drawShadowTint` + aura, percikan ungu lewat `fxr()` di `updateVisuals`. HUD: nama BAYANGAN dengan label nama asli, kelas `.shadow-foe` (story.css). VS: game.js memanggil `Story.decorateVs(info)` tepat setelah `VS.show` (nama 'BAYANGAN <NAMA>', ucapan bos); layar hasil: `ResultView.view` membaca `run.boss` (Bayangan di sisi kalah; kalah dari Bayangan tetap tampil TAMAT). `__game.shadowFoe()` melaporkan keadaan.
- 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).
- `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.
## API uji: `window.__game`

14
game.js
View File

@ -1874,6 +1874,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);
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();
window.Hazard?.step(dt); // bahaya arena (hazard.js): hanya fase fight versus, waktu simulasi
feelWatchKo(); advanceMatch(realDt);
}
// MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them.
@ -2779,8 +2780,10 @@
// 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); }
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(); }
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();
}
function fighterInfo(id,form) {
@ -3010,6 +3013,17 @@
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(window.FRONTEND_ENABLED)setMenuOpen(true);
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">
<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="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"></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"></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">
<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">
@ -27,6 +27,6 @@
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
<!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js -->
<link rel="stylesheet" href="stats.css"><script src="stats.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="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="menu.js"></script><script src="meta.js"></script></body></html>