406 lines
32 KiB
JavaScript
406 lines
32 KiB
JavaScript
/* 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();
|
|
})();
|