/* 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 = '' + TXT.label + '' + TXT.hint + '';
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();
})();