diff --git a/arena.css b/arena.css new file mode 100644 index 0000000..d00dffb --- /dev/null +++ b/arena.css @@ -0,0 +1,19 @@ +/* Latar hidup dan cuaca (arena.js, RENCANA 6.1 dan 6.3): baris Cuaca di layar pilih arena. + Baris ini berdampingan dengan tombol mulai (di sisi kirinya, yang sebelumnya kosong), jadi tinggi layar tidak bertambah di 1280x720 maupun 1024x520. + Baris Pengaturan "Efek latar" memakai kelas .setting-row bawaan, tanpa gaya tambahan. */ +.arena-row{display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:14px 18px} +.arena-row .start-battle{flex:none;margin-left:auto} +.arena-weather{flex:1 1 340px;min-width:0} +.arena-weather .eyebrow{display:flex;align-items:baseline;flex-wrap:wrap;gap:2px 10px} +.aw-note{font-style:normal;font-weight:600;font-size:10px;letter-spacing:.6px;color:#8fb0b3} +.aw-chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px} +.aw-chips button{min-width:0;background:#142d38;color:#c4d3cf;border:1px solid #789a984d;padding:5px 10px;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase} +.aw-chips button.selected{background:#c19b60;color:#0c242e;border-color:#e8cd91} +.aw-chips button:disabled{opacity:.4;cursor:default} +@media(max-height:420px){.aw-chips{margin-top:3px}.aw-chips button{padding:3px 8px;font-size:11px}.aw-note{display:none}} +html.touch .aw-chips button{min-height:30px} + +/* Vinyet dan kerudung warna depan: elemen DOM yang dipasang arena.js tepat di atas kanvas (di bawah HUD). Nilainya lewat --av (rgb vinyet), --ak (kekuatan di tepi), --aw (kerudung). */ +#arena-vig{position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0;transition:opacity .5s;background:radial-gradient(ellipse at 50% 52%,rgba(var(--av,0,0,0),0) 50%,rgba(var(--av,0,0,0),var(--ak,0)) 100%),var(--aw,transparent)} +html.acc-calm #arena-vig{transition:none} +@media(prefers-reduced-motion:reduce){#arena-vig{transition:none}} diff --git a/arena.js b/arena.js new file mode 100644 index 0000000..450c691 --- /dev/null +++ b/arena.js @@ -0,0 +1,536 @@ +/* Latar hidup dan cuaca (RENCANA 6.1 dan 6.3). Semua digambar prosedural di kanvas: tanpa gambar baru, tanpa pustaka. + Kait di game.js (draw): Arena.back(ctx, env) tepat sesudah drawBackground, di belakang petarung (warna cuaca, awan, burung, kilau air, perahu, debu cahaya, + daun, kunang-kunang, hujan jauh, kabut, sorotan lantai); Arena.front(ctx) paling akhir, layar penuh (penonton di tepi bawah, kabut depan, hujan dekat, kilat). + Vinyet dan kerudung warna depan adalah elemen DOM #arena-vig (gaya di arena.css) tepat di atas kanvas: nol biaya gambar, dan tidak ikut zoom kamera. + Biaya: operasi layar penuh dijaga sedikit (vinyet berskala dan blend besar sangat mahal pada rasterisasi perangkat lunak), tekstur kabut dibuat sesuai ukuran layar + lalu digambar 1:1, gradasi langit memakai strip 1x256 yang diregangkan, dan blend besar memakai source-over. + env = { viewW, viewH, scale, offsetX, offsetY, shakeX, shakeY, groundY, stage, time (realTime), menu, combo (comboHits), cin (cinematic) }. + Kait di menu.js: Arena.decorate(root, screen) di akhir render() menambah baris Cuaca di layar arena, dan startMatch({weather: Arena.pick}). Pengaturan mendapat + baris "Efek latar" (Penuh/Ringan/Mati) yang disisipkan modul ini sendiri. + Aturan: hanya tampilan. Semua gerak adalah fungsi waktu tanpa status (hash bilangan bulat sebagai pengacak kosmetik, bukan random() mesin dan bukan Date), jadi hasil + pertandingan tidak berubah, jeda membekukan latar, dan lompatan waktu dari advance() di uji tidak merusak apa pun. Satu-satunya status adalah semangat penonton. + Gerak dikurangi (Access.motion = 0 atau prefers-reduced-motion): waktu latar dibekukan, burung/perahu/daun/debu/bintang jatuh/kilat/lompatan penonton dimatikan. + Konsol: Arena.state(), Arena.force('hujan') (null = lepas), Arena.setFx('light'); URL ?cuaca=hujan memaksa cuaca di semua pertandingan. + Bila ada galat gambar, modul mematikan diri setelah beberapa kali supaya loop game tidak pernah terganggu. */ +(() => { + 'use strict'; + const W = 1280, H = 720, TAU = Math.PI * 2; + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + const lerp = (a, b, k) => a + (b - a) * k; + const fract = v => v - Math.floor(v); + const smooth = u => u * u * (3 - 2 * u); + // Pengacak kosmetik tanpa status (hash bilangan bulat -> 0..1). Tiap efek punya salt sendiri; i = nomor elemen (+ siklus * 64), k = nomor parameter (< 16). + function hash(n) { n |= 0; n = Math.imul(n ^ (n >>> 16), 0x7feb352d); n = Math.imul(n ^ (n >>> 15), 0x846ca68b); return ((n ^ (n >>> 16)) >>> 0) / 4294967296; } + const rnd = (salt, i, k) => hash(salt * 100003 + i * 16 + k); + const rgb = (r, g, b) => 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')'; + const rgba = (c, a) => 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + clamp(a, 0, 1).toFixed(3) + ')'; + + const KEY = 'konoha.arena.v1'; + const PICKS = ['auto', 'cerah', 'hujan', 'kabut', 'senja', 'malam']; + const NAME = { auto: 'Otomatis', cerah: 'Cerah', hujan: 'Hujan', kabut: 'Kabut', senja: 'Senja', malam: 'Malam' }; + const FX = ['full', 'light', 'off'], FXNAME = { full: 'Penuh', light: 'Ringan', off: 'Mati' }; + const DEF_FX = document.documentElement.classList.contains('touch') ? 'light' : 'full'; // HP: Ringan sebagai bawaan + const S = { fx: DEF_FX, pick: 'auto', active: 'auto', forced: null }; // pick = pilihan di layar arena; active = cuaca pertandingan berjalan; forced = ?cuaca= atau Arena.force + const E = { ok: false, dead: false, fails: 0, depth: 0, last: 0, dt: 0, spot: 640, on: false }; + const C = { ex: 0, hold: 0, clap: 0, last: 0, ko: false }; // semangat penonton + + // ---- Data arena (koordinat 1280x720 pada gambar latar) ---- + // horizon = garis perbukitan; sky = batas bawah langit terbuka untuk bintang; sun = letak matahari saat senja; cloud/bird = jalur awan dan burung (v0..v1; tanpa cloud = tanpa langit yang terlihat); keep = kotak [u0, u1, v0, v1] + // bangunan tinggi di depan langit (awan dan bintang memudar saat menutupinya, karena awan digambar di atas gambar latar); water = kolam kilau air (cu/cv = sudut dermaga yang dikecualikan); + // boat = perahu kecil; dust = debu cahaya; fire = kunang-kunang; auto = [cuaca, kekuatan] untuk pilihan Otomatis; crowd = siluet penonton. + const ARENA = { + _: { auto: ['cerah', 1], horizon: 340, sun: [900, 250], cloud: { n: 4, v0: 25, v1: 240 }, bird: { style: 'swallow', v0: 60, v1: 250 }, crowd: { tone: '#2a2433', rim: '#9a8fbf' } }, + bellora: { auto: ['cerah', 1], horizon: 335, sky: 200, sun: [1010, 235], cloud: { n: 4, v0: 25, v1: 200 }, keep: [[375, 500, 0, 330], [0, 190, 115, 330], [1000, 1280, 105, 330], [760, 800, 222, 330]], bird: { style: 'swallow', v0: 60, v1: 240 }, crowd: { tone: '#2b2236', rim: '#8a76b6', acc: 'flag', flags: ['#2f5aa8', '#e0b84a'] } }, + sunspire: { auto: ['cerah', 1], horizon: 470, sky: 330, sun: [640, 330], cloud: { n: 5, v0: 15, v1: 240 }, keep: [[105, 185, 150, 480], [155, 375, 200, 500], [855, 1280, 290, 530], [1145, 1240, 170, 500]], bird: { style: 'swallow', v0: 50, v1: 300 }, dust: { n: 26, v0: 150, v1: 540, col: 'gold' }, shaft: 1, crowd: { tone: '#3a2630', rim: '#e8b27a', acc: 'flag', flags: ['#c04a4a', '#f0c860'] } }, + harbor: { auto: ['cerah', 1], horizon: 405, sky: 300, sun: [560, 300], cloud: { n: 3, v0: 10, v1: 190 }, keep: [[855, 950, 55, 345], [1045, 1280, 95, 265], [300, 455, 235, 440]], bird: { style: 'gull', v0: 55, v1: 290 }, water: { u0: 238, u1: 604, v0: 432, v1: 504, cu: 540, cv: 448 }, boat: { v: 470, u0: 255, u1: 585 }, crowd: { tone: '#1c2a38', rim: '#7fb6d8', acc: 'hat' } }, + elderwood: { auto: ['kabut', .5], horizon: 400, sun: [470, 100], leaves: { n: 14 }, dust: { n: 22, v0: 120, v1: 520, col: 'leaf' }, crowd: { tone: '#101f14', rim: '#8fd08a', acc: 'hat' } }, + moonrise: { auto: ['malam', 1], night: 1, horizon: 420, sky: 330, sun: [200, 410], cloud: { n: 3, v0: 30, v1: 300 }, keep: [[960, 1130, 100, 520]], bird: { style: 'bat', v0: 80, v1: 330 }, water: { u0: 440, u1: 640, v0: 445, v1: 515, dim: .6 }, fire: { n: 16 }, crowd: { tone: '#080b22', rim: '#8fa4ff', acc: 'lantern' } } + }; + // ---- Cuaca ---- + // mul = warna pengali di atas latar [r, g, b, kekuatan]; veil = kerudung warna biasa; top = gradasi dari atas (langit); glow = cahaya cakrawala; cloud = nada awan, cn = jumlah awan; + // rain/fog/mist = tingkat 0..1; vig = [nada vinyet, kekuatan]; spot = sorotan lantai; bolt = kilat. + const WX = { + cerah: {}, + hujan: { mul: [170, 184, 204, .9], veil: [96, 110, 126, .14], top: [54, 66, 82, .5], cloud: 'storm', cn: 7, rain: 1, mist: .4, vig: ['dark', .34], bolt: 1 }, + kabut: { veil: [222, 230, 235, .24], top: [234, 240, 244, .3], cloud: 'mist', cn: 3, fog: 1, vig: ['dark', .1] }, + senja: { mul: [255, 186, 134, .85], top: [88, 54, 130, .34], glow: [255, 140, 78, .34], sun: 1, spot: 'warm', cloud: 'dusk', vig: ['dusk', .3] }, + malam: { mul: [86, 104, 172, .78], spot: 'cool', cloud: 'night', vig: ['night', .46] } + }; + const FRONT_VEIL = { hujan: [70, 84, 100, .07], kabut: [222, 230, 236, .07], senja: [255, 150, 70, .06], malam: [10, 20, 60, .1] }; + + // ---- Sprite kecil: digambar sekali di kanvas luar layar lalu dipakai ulang lewat drawImage ---- + const SPR = {}; + function sprite(key, w, h, paint) { + let c = SPR[key]; + if (!c) { c = document.createElement('canvas'); c.width = Math.max(1, Math.round(w)); c.height = Math.max(1, Math.round(h)); paint(c.getContext('2d'), c.width, c.height); SPR[key] = c; } + return c; + } + const GLOW = { gold: [255, 218, 128], leaf: [214, 238, 150], fly: [214, 255, 120], warm: [255, 198, 112], sun: [255, 190, 110], cool: [200, 222, 255] }; + const glow = name => sprite('g' + name, 64, 64, (x, w) => { const c = GLOW[name], g = x.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2); g.addColorStop(0, rgba(c, 1)); g.addColorStop(.3, rgba(c, .55)); g.addColorStop(1, rgba(c, 0)); x.fillStyle = g; x.fillRect(0, 0, w, w); }); + const CLOUD = { + day: { hi: [255, 255, 255], lo: [182, 206, 232], a: .92, o: .85 }, dusk: { hi: [255, 218, 196], lo: [150, 100, 150], a: .9, o: .85 }, night: { hi: [128, 146, 208], lo: [40, 50, 104], a: .8, o: .55 }, + storm: { hi: [156, 166, 178], lo: [72, 82, 96], a: .95, o: .9 }, mist: { hi: [228, 234, 238], lo: [172, 184, 196], a: .8, o: .6 } + }; + const CLOUD_SHAPE = [ + { w: 300, h: 130, sy: 1, c: [[60, 76, 32], [105, 58, 42], [160, 48, 50], [215, 60, 42], [256, 80, 30], [150, 78, 38], [100, 86, 32], [205, 88, 32]] }, + { w: 176, h: 96, sy: 1, c: [[44, 56, 26], [84, 44, 34], [128, 54, 28], [90, 56, 30]] }, + { w: 350, h: 64, sy: .5, c: [[50, 52, 36], [112, 50, 44], [182, 54, 46], [252, 52, 40], [304, 56, 28]] } + ]; + function cloud(kind, tone) { + const sh = CLOUD_SHAPE[kind], T = CLOUD[tone], K = 1.5; + return sprite('c' + kind + tone, sh.w * K, sh.h * K, x => { + x.scale(K, K * sh.sy); + const blob = (cx, cy, r, c, a) => { const g = x.createRadialGradient(cx, cy, r * .12, cx, cy, r); g.addColorStop(0, rgba(c, a)); g.addColorStop(.6, rgba(c, a * .7)); g.addColorStop(1, rgba(c, 0)); x.fillStyle = g; x.beginPath(); x.arc(cx, cy, r, 0, TAU); x.fill(); }; + for (const [cx, cy, r] of sh.c) blob(cx, cy + r * .26, r, T.lo, T.a * .8); + for (const [cx, cy, r] of sh.c) blob(cx, cy - r * .06, r * .9, T.hi, T.a); + }); + } + const FOG = { day: [224, 232, 238], gray: [170, 182, 194], night: [96, 112, 156] }; + const fogTex = (tone, v) => sprite('f' + tone + v, 512, 160, (x, w, h) => { + const c = FOG[tone]; + for (let i = 0; i < 16; i++) { + const cx = rnd(10 + v, i, 1) * w, cy = h * (.38 + .3 * rnd(10 + v, i, 2)), r = 58 + 70 * rnd(10 + v, i, 3), a = .35 + .3 * rnd(10 + v, i, 4); + for (const dx of [-w, 0, w]) { // tiga salinan supaya ubin menyambung di sisi kiri dan kanan + x.save(); x.translate(cx + dx, cy); x.scale(1, .42); + const g = x.createRadialGradient(0, 0, 0, 0, 0, r); g.addColorStop(0, rgba(c, a)); g.addColorStop(.55, rgba(c, a * .45)); g.addColorStop(1, rgba(c, 0)); + x.fillStyle = g; x.beginPath(); x.arc(0, 0, r, 0, TAU); x.fill(); x.restore(); + } + } + x.globalCompositeOperation = 'destination-in'; // pudar di atas dan bawah supaya pita tidak bertepi keras + const m = x.createLinearGradient(0, 0, 0, h); m.addColorStop(0, 'rgba(0,0,0,0)'); m.addColorStop(.4, 'rgba(0,0,0,1)'); m.addColorStop(.7, 'rgba(0,0,0,1)'); m.addColorStop(1, 'rgba(0,0,0,0)'); x.fillStyle = m; x.fillRect(0, 0, w, h); + }); + const spotSpr = tone => sprite('s' + tone, 128, 64, (x, w, h) => { + const c = GLOW[tone === 'warm' ? 'warm' : 'cool']; + x.translate(w / 2, h / 2); x.scale(1, h / w); const g = x.createRadialGradient(0, 0, 0, 0, 0, w / 2); g.addColorStop(0, rgba(c, 1)); g.addColorStop(.5, rgba(c, .45)); g.addColorStop(1, rgba(c, 0)); x.fillStyle = g; x.beginPath(); x.arc(0, 0, w / 2, 0, TAU); x.fill(); + }); + // Berkas cahaya (Sunspire): gradasi vertikal lalu ditipiskan di kedua sisi (destination-in). + const shaftSpr = () => sprite('shaft', 64, 256, (x, w, h) => { + const g = x.createLinearGradient(0, 0, 0, h); g.addColorStop(0, 'rgba(255,246,214,.9)'); g.addColorStop(1, 'rgba(255,246,214,0)'); x.fillStyle = g; x.fillRect(0, 0, w, h); + x.globalCompositeOperation = 'destination-in'; const m = x.createLinearGradient(0, 0, w, 0); m.addColorStop(0, 'rgba(0,0,0,0)'); m.addColorStop(.5, 'rgba(0,0,0,1)'); m.addColorStop(1, 'rgba(0,0,0,0)'); x.fillStyle = m; x.fillRect(0, 0, w, h); + }); + // Strip gradasi 1x256 (alpha penuh di atas lalu memudar, atau puncak di tengah bila peak diberikan); diregangkan lebar saat digambar (jauh lebih murah daripada gradasi per frame). + const strip = (c, peak) => sprite('t' + c[0] + ',' + c[1] + ',' + c[2] + (peak || ''), 1, 256, (x, w, h) => { + const g = x.createLinearGradient(0, 0, 0, h); + if (peak) { g.addColorStop(0, rgba(c, 0)); g.addColorStop(peak, rgba(c, 1)); g.addColorStop(1, rgba(c, 0)); } else { g.addColorStop(0, rgba(c, 1)); g.addColorStop(1, rgba(c, 0)); } + x.fillStyle = g; x.fillRect(0, 0, w, h); + }); + const VIG = { dark: [0, 0, 0], night: [4, 8, 36], dusk: [44, 10, 40] }; + + // ---- Penyimpanan, pilihan, pengaturan ---- + function load() { try { const o = JSON.parse(localStorage.getItem(KEY) || '{}'); if (FX.includes(o.fx)) S.fx = o.fx; } catch (_) { /* tanpa penyimpanan: pakai bawaan */ } } + function save() { try { if (S.fx === DEF_FX) localStorage.removeItem(KEY); else localStorage.setItem(KEY, JSON.stringify({ fx: S.fx })); } catch (_) { /* penyimpanan diblokir */ } } + try { const q = new URLSearchParams(location.search).get('cuaca'); if (PICKS.includes(q) && q !== 'auto') S.forced = q; } catch (_) { /* tanpa URL */ } + const motion = (() => { let mq = null; try { mq = matchMedia('(prefers-reduced-motion: reduce)'); } catch (_) { /* tanpa matchMedia */ } return () => { try { return window.Access ? window.Access.motion : mq && mq.matches ? 0 : 1; } catch (_) { return 1; } }; })(); + function resolve(stage) { + const p = S.forced || S.active; + if (p && p !== 'auto' && WX[p]) return [p, 1]; + return (ARENA[stage] || ARENA._).auto; + } + const autoLabel = stage => { const r = (ARENA[stage] || ARENA._).auto; return NAME[r[0]] + (r[1] < 1 ? ' tipis' : ''); }; + + // ---- Lapisan DOM: vinyet dan kerudung warna depan (#arena-vig di atas kanvas, di bawah HUD); DOM hanya ditulis saat nilainya berubah ---- + let OV = null, ovKey = '', ovSig = null; + function syncOverlay() { + if (!OV) { const cv = document.getElementById('arena'); if (!cv || !cv.parentNode) return; OV = document.createElement('div'); OV.id = 'arena-vig'; OV.setAttribute('aria-hidden', 'true'); cv.after(OV); } + const sig = E.on ? E.wx + '|' + E.wk + '|' + E.light + '|' + !!E.A.night : ''; + if (sig === ovSig) return; ovSig = sig; + let av = '0,0,0', ak = '0', aw = 'transparent', key = ''; + if (E.on) { + const v = E.P.vig, w = FRONT_VEIL[E.wx], k = E.wk; + if (v) { av = VIG[v[0]].join(','); ak = clamp(v[1] * k, 0, 1).toFixed(2); } + if (w && !E.light) aw = rgba(w, w[3] * k * (w[0] > 200 && E.A.night ? .35 : 1)); + if (v || w) key = av + '|' + ak + '|' + aw; + } + if (key === ovKey) return; ovKey = key; + if (!key) { OV.style.opacity = '0'; return; } + OV.style.setProperty('--av', av); OV.style.setProperty('--ak', ak); OV.style.setProperty('--aw', aw); OV.style.opacity = '1'; + } + + // ---- Per frame ---- + function push(ctx) { ctx.save(); E.depth++; } + function pop(ctx) { ctx.restore(); E.depth--; } + function prep(e) { + E.vw = e.viewW; E.vh = e.viewH; E.sc = e.scale; E.ox = e.offsetX; E.oy = e.offsetY; E.shx = e.shakeX * e.scale; E.shy = e.shakeY * e.scale; + E.gy = e.groundY; E.stage = e.stage; E.t = e.time; E.menu = !!e.menu; E.combo = e.combo | 0; E.fade = 1 - Math.min(.65, (e.cin || 0) * 1.5); // fade = redup latar saat sinematik ultimate + const dt = clamp(e.time - E.last, 0, .1); E.last = e.time; E.dt = dt; + E.on = !E.menu && S.fx !== 'off'; + if (E.menu) { C.ex = C.hold = C.clap = C.last = 0; C.ko = false; } + if (!E.on) { syncOverlay(); return; } + E.light = S.fx === 'light'; E.calm = motion() < 1; E.T = E.calm ? 0 : e.time; + // Pemetaan gambar latar ke layar sama dengan drawBackground (menutupi layar, lantai gambar sejajar lantai tempur). + const bs = Math.max(E.vw / W, E.vh / H); E.bs = bs; E.ix = (E.vw - W * bs) / 2; E.iy = E.vh * .815 - E.gy * bs; + E.uL = -E.ix / bs; E.uR = (E.vw - E.ix) / bs; E.vT = -E.iy / bs; E.vB = (E.vh - E.iy) / bs; + E.wL = -E.ox / E.sc; E.wR = (E.vw - E.ox) / E.sc; E.wB = (E.vh - E.oy) / E.sc; // sisi tampak dunia (lantai di E.gy) + const r = resolve(E.stage); E.wx = r[0]; E.wk = r[1]; E.A = ARENA[E.stage] || ARENA._; E.P = WX[E.wx]; + E.night = E.wx === 'malam' || (!!E.A.night && E.wx === 'cerah'); // langit malam: bintang, kelelawar, kunang-kunang + syncOverlay(); + if (!E.calm) crowdTick(dt); + } + function fail(ctx, err) { + while (E.depth > 0) { try { ctx.restore(); } catch (_) { /* tumpukan sudah kosong */ } E.depth--; } + E.ok = false; if (++E.fails <= 2) console.error('[arena] galat gambar:', err); + if (E.fails >= 6) { E.dead = true; console.error('[arena] dimatikan sesudah galat berulang'); if (OV) OV.style.opacity = '0'; } + } + function back(ctx, e) { + E.ok = false; + try { if (E.dead || !ctx || !e) return; prep(e); if (!E.on) return; drawBack(ctx); E.ok = true; } catch (err) { fail(ctx, err); } + } + function front(ctx) { + if (!E.ok) return; E.ok = false; + try { drawFront(ctx); } catch (err) { fail(ctx, err); } + } + + // ---- Penonton: semangat naik saat kombo besar (5, 8, 12 pukulan), saat kombo besar berakhir (tepuk), dan saat K.O. ---- + function crowdTick(dt) { + const F = window.Feel, ko = !!(F && F.ko && F.ko.state && F.ko.state.on), cb = E.combo; + const pulse = v => { if (v > C.ex) C.ex = v; }; + if (cb > C.last) { if (C.last < 12 && cb >= 12) pulse(1); else if (C.last < 8 && cb >= 8) pulse(.8); else if (C.last < 5 && cb >= 5) pulse(.55); } + else if (cb === 0 && C.last >= 5) { C.clap = 1.3; pulse(.45); } + C.last = cb; + if (ko && !C.ko) { C.hold = F.ko.state.final ? 2.6 : 1.7; pulse(1); } + C.ko = ko; + C.hold = Math.max(0, C.hold - dt); C.clap = Math.max(0, C.clap - dt); + C.ex = C.hold > 0 ? Math.max(C.ex, .9) : Math.max(0, C.ex - dt * .5); + } + + // ---- Lapisan belakang ---- + function drawBack(ctx) { + const A = E.A, P = E.P, k = E.wk, ga = E.fade; + push(ctx); ctx.imageSmoothingEnabled = true; + tintBack(ctx, A, P, k); + push(ctx); ctx.translate(E.ix + E.shx, E.iy + E.shy); ctx.scale(E.bs, E.bs); imageLayers(ctx, A, P, k, ga); pop(ctx); + if (P.rain) rain(ctx, true, k); + if (P.fog) { fogBand(ctx, 0, k); if (!E.light) fogBand(ctx, 1, k); } + else if (P.mist) fogBand(ctx, 0, P.mist * k); + push(ctx); ctx.translate(E.ox + E.shx, E.oy + E.shy); ctx.scale(E.sc, E.sc); + if (P.rain && !E.calm) splashes(ctx, ga); + pop(ctx); + if (P.spot) spotlight(ctx, P, k, ga); + pop(ctx); + } + function tintBack(ctx, A, P, k) { + const lt = A.night ? .35 : 1; // arena yang sudah malam: kerudung terang dikurangi, tint gelap tidak digandakan + if (P.mul) { + const s = P.mul[3] * k * (A.night && E.wx === 'malam' ? .3 : 1); + ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = rgb(lerp(255, P.mul[0], s), lerp(255, P.mul[1], s), lerp(255, P.mul[2], s)); ctx.fillRect(0, 0, E.vw, E.vh); ctx.globalCompositeOperation = 'source-over'; + } + if (P.veil && !E.light) { ctx.globalAlpha = P.veil[3] * k * (P.veil[0] > 200 ? lt : 1); ctx.fillStyle = rgb(P.veil[0], P.veil[1], P.veil[2]); ctx.fillRect(0, 0, E.vw, E.vh); ctx.globalAlpha = 1; } + } + // Semua yang menempel pada gambar latar (koordinat gambar 1280x720; sudah di-translate dan di-scale). + function imageLayers(ctx, A, P, k, ga) { + const uL = E.uL, uR = E.uR, vT = E.vT, light = E.light, calm = E.calm, lt = A.night ? .35 : 1; + if (P.top && !light) { const bot = A.horizon + 30; ctx.globalAlpha = clamp(P.top[3] * k * (P.top[0] > 200 ? lt : 1), 0, 1); ctx.drawImage(strip(P.top), 0, 0, 1, 256, uL, vT, uR - uL, Math.max(40, bot - vT)); ctx.globalAlpha = 1; } + if (P.glow && !light) { ctx.globalAlpha = clamp(P.glow[3] * k * 2, 0, 1); ctx.drawImage(strip(P.glow, .7), 0, 0, 1, 256, uL, A.horizon - 170, uR - uL, 240); ctx.globalAlpha = 1; } + if (P.sun) { const sp = glow('sun'), su = A.sun[0], sv = A.sun[1]; ctx.globalAlpha = .55 * k * ga; ctx.drawImage(sp, su - 260, sv - 260, 520, 520); ctx.globalAlpha = .6 * k * ga; ctx.drawImage(sp, su - 80, sv - 80, 160, 160); ctx.globalAlpha = 1; } + if (E.night && A.cloud) stars(ctx, A, ga); + if (A.shaft && !calm && !light && (E.wx === 'cerah' || E.wx === 'senja')) shafts(ctx, k, ga); + if (A.cloud) clouds(ctx, A, P, ga); + if (!calm && A.bird) { const st = E.night ? 'bat' : (E.wx === 'cerah' || E.wx === 'senja') ? A.bird.style : ''; if (st) birds(ctx, A, st, ga); } + if (A.water && (E.wx === 'cerah' || E.wx === 'senja' || E.wx === 'malam')) water(ctx, A, ga); + if (!calm && A.dust && (E.wx === 'cerah' || E.wx === 'senja' || E.wx === 'kabut')) dust(ctx, A, k, ga); + if (!calm && A.leaves && E.wx !== 'malam' && E.wx !== 'hujan') leaves(ctx, A, ga); + if (E.night) { fireflies(ctx, A, ga); if (!calm && !light && A.cloud) shooting(ctx, ga); } + } + function stars(ctx, A, ga) { + const n = E.light ? 12 : 26, T = E.T, v1 = Math.max(E.vT + 40, A.sky || A.horizon - 50); + ctx.fillStyle = '#eaf2ff'; + for (let i = 0; i < n; i++) { + const tw = .5 + .5 * Math.sin(T * (1 + 2 * rnd(1, i, 2)) + rnd(1, i, 3) * TAU), s = 1 + 1.4 * rnd(1, i, 4), u = lerp(E.uL, E.uR, rnd(1, i, 1)), v = lerp(E.vT + 4, v1, rnd(1, i, 5)), K = A.keep; + let hid = false; if (K) for (let j = 0; j < K.length; j++) { const q = K[j]; if (u > q[0] && u < q[1] && v > q[2] && v < q[3]) { hid = true; break; } } + if (hid) continue; + ctx.globalAlpha = (.25 + .65 * tw) * ga * (A.night ? .8 : 1); ctx.fillRect(u, v, s, s); + } + ctx.globalAlpha = 1; + } + function shafts(ctx, k, ga) { // berkas cahaya miring dari kiri atas (Sunspire) + const spr = shaftSpr(), T = E.T, h = Math.min(E.vB, 560) - E.vT; + for (let i = 0; i < 3; i++) { + const w = 130 + 70 * rnd(2, i, 1), x = lerp(E.uL, E.uR, (i + .3 + .4 * rnd(2, i, 2)) / 3); + ctx.globalAlpha = (.07 + .05 * (.5 + .5 * Math.sin(T * .22 + i * 2.1))) * k * ga; + push(ctx); ctx.translate(x, E.vT); ctx.transform(1, 0, .32, 1, 0, 0); ctx.drawImage(spr, -w / 2, 0, w, h); pop(ctx); + } + ctx.globalAlpha = 1; + } + function clouds(ctx, A, P, ga) { + const n = Math.ceil((P.cn || A.cloud.n) * (E.light ? .6 : 1)), tone = P.cloud || (A.night ? 'night' : 'day'), co = CLOUD[tone].o, T = E.T, uL = E.uL, uR = E.uR; + const vlo = Math.max(A.cloud.v0, E.vT + 6), vhi = Math.max(vlo + 20, Math.min(A.cloud.v1, A.horizon - 24)), fast = tone === 'storm' ? 1.7 : 1; + for (let i = 0; i < n; i++) { + const sh = CLOUD_SHAPE[i % 3], sz = .75 + .6 * rnd(3, i, 1), w = sh.w * sz, h = sh.h * sz, span = (uR - uL) + 2 * w; + const x = uL - w + fract((rnd(3, i, 2) * span + T * (4 + 7 * rnd(3, i, 3)) * fast) / span) * span, y = lerp(vlo, vhi, rnd(3, i, 4)) + Math.sin(T * .05 + i * 1.7) * 3, K = A.keep; + let vis = 1; + if (K) for (let j = 0; j < K.length; j++) { const q = K[j], ox = Math.min(x + w, q[1]) - Math.max(x, q[0]), oy = Math.min(y + h / 2, q[3]) - Math.max(y - h / 2, q[2]); if (ox > 0 && oy > 0) vis *= 1 - clamp(ox * oy / (w * h) * 4, 0, 1); } + if (vis < .02) continue; + ctx.globalAlpha = co * (.78 + .22 * rnd(3, i, 5)) * ga * vis; ctx.drawImage(cloud(i % 3, tone), x, y - h / 2, w, h); + } + ctx.globalAlpha = 1; + } + // Burung melintas sesekali: tiap slot punya siklus (30-60 dtk) dan melintas satu kali per siklus; arah, tinggi, dan jumlah diundi per siklus. + function birds(ctx, A, style, ga) { + const T = E.t, B = A.bird, slots = E.light ? 1 : 3, vlo = Math.max(B.v0, E.vT + 10), vhi = Math.max(vlo + 20, B.v1), span = E.uR - E.uL; + ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + for (let i = 0; i < slots; i++) { + const per = 30 + 30 * rnd(4, i, 1), ph = rnd(4, i, 2) * per, cyc = Math.floor((T + ph) / per), age = T + ph - cyc * per, id = i + cyc * 64; + const sp = (style === 'bat' ? 110 : 85) + 55 * rnd(4, id, 3), pass = (span + 160) / sp; + if (age >= pass) continue; + const dir = rnd(4, id, 4) < .5 ? 1 : -1, n = 1 + Math.floor(rnd(4, id, 5) * (style === 'gull' ? 2.99 : 3.99)), base = lerp(vlo, vhi, rnd(4, id, 6)); + const sz = (style === 'gull' ? 9 : style === 'bat' ? 8 : 6.5) * (.85 + .3 * rnd(4, id, 7)), x0 = dir > 0 ? E.uL - 80 + age * sp : E.uR + 80 - age * sp; + for (let j = 0; j < n; j++) { + const bx = x0 - dir * j * sz * 3.2, by = base + Math.sin(age * 1.3 + j * .9 + id) * (style === 'bat' ? 16 : 7) + j * sz * 1.4 * (j % 2 ? 1 : -1); + birdShape(ctx, style, bx, by, sz, Math.sin(T * (style === 'bat' ? 15 : 9) + j * 1.7 + i * 3.1), ga); + } + } + } + function birdShape(ctx, style, x, y, s, fl, ga) { + const a = fl * s * .55; + ctx.beginPath(); + if (style === 'bat') { + ctx.moveTo(x, y + s * .15); + ctx.quadraticCurveTo(x - s * .7, y - s * (.9 + .5 * fl), x - s * 1.5, y - s * .1 * fl); ctx.quadraticCurveTo(x - s * .95, y + s * .05, x - s * .55, y + s * .5); ctx.quadraticCurveTo(x - s * .15, y + s * .2, x, y + s * .5); + ctx.quadraticCurveTo(x + s * .15, y + s * .2, x + s * .55, y + s * .5); ctx.quadraticCurveTo(x + s * .95, y + s * .05, x + s * 1.5, y - s * .1 * fl); ctx.quadraticCurveTo(x + s * .7, y - s * (.9 + .5 * fl), x, y + s * .15); + ctx.closePath(); ctx.globalAlpha = .88 * ga; ctx.fillStyle = '#080a1e'; ctx.fill(); + } else { + ctx.moveTo(x - s * 1.25, y - a); ctx.quadraticCurveTo(x - s * .5, y - s * .7 + a * .3, x, y); ctx.quadraticCurveTo(x + s * .5, y - s * .7 + a * .3, x + s * 1.25, y - a); + if (style === 'gull') { ctx.globalAlpha = .4 * ga; ctx.strokeStyle = '#3d5568'; ctx.lineWidth = s * .42; ctx.stroke(); ctx.globalAlpha = .95 * ga; ctx.strokeStyle = '#fbfdff'; ctx.lineWidth = s * .26; ctx.stroke(); } + else { ctx.globalAlpha = .8 * ga; ctx.strokeStyle = '#28323f'; ctx.lineWidth = Math.max(1.3, s * .24); ctx.stroke(); } + } + ctx.globalAlpha = 1; + } + // Kilau air (garis silang kecil, satu goresan) dan perahu layar kecil yang melintas pelan (Azure Harbor); Moonrise hanya kilau di sungai. + function water(ctx, A, ga) { + const Wt = A.water, T = E.T, n = Math.round((E.light ? 12 : 26) * (Wt.dim || 1)); + ctx.globalCompositeOperation = 'lighter'; ctx.strokeStyle = E.night ? 'rgb(190,214,255)' : E.wx === 'senja' ? 'rgb(255,214,170)' : 'rgb(236,250,255)'; ctx.lineWidth = 1.2; ctx.lineCap = 'round'; ctx.globalAlpha = .85 * ga * (E.night ? .75 : 1); + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const per = 1.5 + 2.4 * rnd(5, i, 1), ph = rnd(5, i, 2) * per, c = Math.floor((T + ph) / per), age = (T + ph) / per - c, b = Math.pow(Math.sin(age * Math.PI), 3); + if (b < .08) continue; + const u = lerp(Wt.u0, Wt.u1, rnd(5, i + c * 64, 3)), v = lerp(Wt.v0, Wt.v1, rnd(5, i + c * 64, 4)), s = 1 + 5 * b; + if (Wt.cu && u > Wt.cu && v < Wt.cv) continue; // sudut dermaga bukan air + ctx.moveTo(u - s, v); ctx.lineTo(u + s, v); ctx.moveTo(u, v - s * .7); ctx.lineTo(u, v + s * .7); + } + ctx.stroke(); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1; + const B = A.boat; + if (B && !E.calm && (E.wx === 'cerah' || E.wx === 'senja')) { + const per = 120, c = Math.floor((E.t + 23) / per), age = E.t + 23 - c * per, dur = 80; + if (age < dur) { + const dir = c % 2 ? -1 : 1, p = age / dur, x = dir > 0 ? lerp(B.u0, B.u1, p) : lerp(B.u1, B.u0, p); + push(ctx); ctx.translate(x, B.v + Math.sin(E.t * 1.1) * 1.4); ctx.rotate(Math.sin(E.t * .8) * .03); ctx.scale(dir, 1); ctx.globalAlpha = Math.min(1, p * 8, (1 - p) * 8) * ga; + ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(-26, 8); ctx.lineTo(-58, 10); ctx.moveTo(-22, 10); ctx.lineTo(-48, 13); ctx.stroke(); // jejak air + ctx.fillStyle = '#3b2c25'; ctx.beginPath(); ctx.moveTo(-24, 0); ctx.lineTo(24, 0); ctx.lineTo(17, 8); ctx.lineTo(-17, 8); ctx.closePath(); ctx.fill(); // lambung + ctx.fillStyle = '#f2e8cf'; ctx.beginPath(); ctx.moveTo(1, -2); ctx.lineTo(1, -38); ctx.lineTo(21, -4); ctx.closePath(); ctx.fill(); // layar utama + ctx.fillStyle = '#e2d3b0'; ctx.beginPath(); ctx.moveTo(-2, -4); ctx.lineTo(-2, -30); ctx.lineTo(-17, -4); ctx.closePath(); ctx.fill(); // layar depan + ctx.fillStyle = '#4a3a30'; ctx.fillRect(-.5, -40, 1.6, 40); // tiang + pop(ctx); + } + } + } + function dust(ctx, A, k, ga) { // debu cahaya melayang pelan (Sunspire emas, Elderwood hijau keemasan) + const D = A.dust, n = Math.round(D.n * (E.light ? .5 : 1)), T = E.T, spr = glow(D.col), span = E.uR - E.uL, v0 = Math.max(D.v0, E.vT + 10), v1 = Math.max(v0 + 30, Math.min(D.v1, E.vB - 10)); + ctx.globalCompositeOperation = 'lighter'; + for (let i = 0; i < n; i++) { + const x = E.uL + fract((rnd(6, i, 1) * span + T * (2 + 3 * rnd(6, i, 2))) / span) * span, y = lerp(v0, v1, rnd(6, i, 3)) + Math.sin(T * (.2 + .3 * rnd(6, i, 4)) + rnd(6, i, 5) * TAU) * 14; + const tw = .5 + .5 * Math.sin(T * (.6 + .9 * rnd(6, i, 6)) + rnd(6, i, 7) * TAU), s = 6 + 9 * rnd(6, i, 8); + ctx.globalAlpha = (.12 + .38 * tw) * ga * k; ctx.drawImage(spr, x - s / 2, y - s / 2, s, s); + } + ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1; + } + const LEAF = ['#7fb84a', '#a6c95a', '#d7c05a', '#5f9d4a', '#c9a24a', '#f4a9c7']; // warna daun dan kelopak (indeks 5 = kelopak merah muda) yang jatuh pelan di Elderwood + function leaves(ctx, A, ga) { + const T = E.T, n = Math.round(A.leaves.n * (E.light ? .55 : 1)), span = E.uR - E.uL; + for (let i = 0; i < n; i++) { + const per = 12 + 10 * rnd(7, i, 1), ph = rnd(7, i, 2) * per, c = Math.floor((T + ph) / per), age = (T + ph) / per - c, id = i + c * 64; + const x = E.uL + rnd(7, id, 3) * span + Math.sin(T * (.6 + .8 * rnd(7, id, 4)) + rnd(7, id, 5) * TAU) * 28 + age * per * (-8 + 16 * rnd(7, id, 6)), y = lerp(E.vT - 16, E.gy + 28, age); + const flip = Math.abs(Math.cos(T * (1.2 + 1.2 * rnd(7, id, 7)) + rnd(7, id, 8) * TAU)), sz = 4.5 + 4 * rnd(7, id, 9), ci = Math.floor(rnd(7, id, 10) * LEAF.length); + ctx.globalAlpha = Math.min(1, age * 10) * Math.min(1, (1 - age) * 7) * .9 * ga; ctx.fillStyle = LEAF[ci]; + ctx.beginPath(); ctx.ellipse(x, y, ci === 5 ? sz * .75 : sz, Math.max(.8, sz * .42 * flip), T * (.7 + 1.1 * rnd(7, id, 11)) + rnd(7, id, 12) * TAU, 0, TAU); ctx.fill(); + } + ctx.globalAlpha = 1; + } + function fireflies(ctx, A, ga) { + const n = Math.round(((A.fire && A.fire.n) || 12) * (E.light ? .5 : 1)), T = E.T, spr = glow('fly'), span = E.uR - E.uL, v0 = Math.max(E.vT + 40, E.gy - 280), v1 = Math.max(v0 + 40, Math.min(E.vB - 20, E.gy + 20)); + ctx.globalCompositeOperation = 'lighter'; + for (let i = 0; i < n; i++) { + const x = E.uL + rnd(8, i, 1) * span + Math.sin(T * (.22 + .3 * rnd(8, i, 3)) + rnd(8, i, 4) * TAU) * 30 + Math.sin(T * .09 + rnd(8, i, 5) * TAU) * 44; + const y = lerp(v0, v1, rnd(8, i, 2)) + Math.cos(T * (.27 + .3 * rnd(8, i, 6)) + rnd(8, i, 7) * TAU) * 18; + const bl = .5 + .5 * Math.sin(T * (.7 + 1.1 * rnd(8, i, 8)) + rnd(8, i, 9) * TAU), a = smooth(clamp((bl - .2) / .6, 0, 1)), s = 14 + 14 * rnd(8, i, 10); + if (a < .03) continue; + ctx.globalAlpha = a * .9 * ga; ctx.drawImage(spr, x - s / 2, y - s / 2, s, s); ctx.globalAlpha = a * ga; ctx.drawImage(spr, x - 3, y - 3, 6, 6); + } + ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1; + } + function shooting(ctx, ga) { // bintang jatuh sesekali (tiap 31 dtk, 0,9 dtk) + const per = 31, c = Math.floor((E.t + 9) / per), age = E.t + 9 - c * per; + if (age > .9) return; + const p = age / .9, dx = -330, dy = 190, hx = lerp(E.uL + 100, E.uR - 200, rnd(9, c, 1)) + dx * p, hy = E.vT + 20 + 70 * rnd(9, c, 2) + dy * p, tx = hx - dx * .22, ty = hy - dy * .22; + const g = ctx.createLinearGradient(tx, ty, hx, hy); g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(255,255,255,.95)'); + ctx.globalAlpha = Math.sin(p * Math.PI) * ga; ctx.strokeStyle = g; ctx.lineWidth = 1.6; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(hx, hy); ctx.stroke(); ctx.globalAlpha = 1; + } + // Hujan: garis miring, satu goresan per lapisan; posisi = fungsi waktu (membungkus di tepi layar). far = di belakang petarung, dekat = di depan. + function rain(ctx, far, k) { + const vw = E.vw, vh = E.vh, T = E.T, n = Math.round((far ? 64 : 48) * (E.light ? .5 : 1) * k), spd = (far ? .85 : 1.25) * vh, len = (far ? .03 : .052) * vh, sl = -.2, m = Math.hypot(sl, 1), ux = sl / m, uy = 1 / m; + const PW = vw + 80, PH = vh + len + 20, salt = far ? 11 : 12; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const x = fract(rnd(salt, i, 1) + T * spd * ux / PW) * PW - 40, y = fract(rnd(salt, i, 2) + T * spd * uy / PH) * PH - len; + ctx.moveTo(x, y); ctx.lineTo(x + ux * len, y + uy * len); + } + ctx.lineWidth = far ? 1 : 1.3; ctx.strokeStyle = far ? 'rgba(210,225,240,.3)' : 'rgba(225,238,250,.42)'; ctx.lineCap = 'butt'; ctx.stroke(); // garis tipis tanpa ujung bulat: jauh lebih murah dirasterisasi + } + function splashes(ctx, ga) { // riak kecil di lantai (koordinat dunia) + const n = E.light ? 8 : 16, T = E.T, depth = clamp(E.wB - E.gy - 10, 8, 86); + ctx.lineWidth = 1.4; ctx.strokeStyle = 'rgb(225,238,250)'; + for (let i = 0; i < n; i++) { + const per = .5 + .45 * rnd(13, i, 1), ph = rnd(13, i, 2) * per, c = Math.floor((T + ph) / per), age = (T + ph) / per - c, id = i + c * 64, r = 2 + 9 * age; + ctx.globalAlpha = (1 - age) * (1 - age) * .5 * ga; ctx.beginPath(); ctx.ellipse(lerp(E.wL, E.wR, rnd(13, id, 3)), E.gy + 6 + rnd(13, id, 4) * depth, r, r * .32, 0, 0, TAU); ctx.stroke(); + } + ctx.globalAlpha = 1; + } + // Pita kabut: ubin selebar layar dibuat sekali (dari tekstur dasar) lalu digambar 1:1 pada x bulat dan digeser pelan (koordinat layar). which: 0 belakang bawah, 1 belakang atas, 2 depan. + const FOGB = [[-.27, .36, .5, 14, 0], [-.54, .3, .3, -9, 1], [-.1, .24, .2, 22, 2]], FOGT = [null, null, null]; + function fogTile(which, tone, v, tw, th) { + const key = tone + v + ':' + tw + 'x' + th, e = FOGT[which]; + if (e && e.key === key) return e.c; + const c = document.createElement('canvas'); c.width = tw; c.height = th; c.getContext('2d').drawImage(fogTex(tone, v), 0, 0, tw, th); FOGT[which] = { key, c }; return c; + } + function fogBand(ctx, which, k) { + const f = FOGB[which], tone = E.A.night ? 'night' : E.wx === 'hujan' ? 'gray' : 'day', th = Math.max(8, Math.round(f[1] * E.vh)), tw = Math.max(64, Math.round(E.vw)), top = Math.round(E.oy + E.gy * E.sc + E.shy + f[0] * E.vh); + const tile = fogTile(which, tone, f[4], tw, th); + ctx.globalAlpha = clamp(f[2] * k, 0, 1); + for (let x = -Math.floor(fract((E.T * f[3] * E.sc) / tw + .13 * which) * tw); x < E.vw; x += tw) ctx.drawImage(tile, x, top); + ctx.globalAlpha = 1; + } + function spotlight(ctx, P, k, ga) { // genangan cahaya di lantai yang mengikuti petarung (dunia; source-over supaya murah) + const g = window.__game, hx = g && g.hero && g.hero.x, dx = g && g.dummy && g.dummy.x, mid = typeof hx === 'number' && typeof dx === 'number' && isFinite(hx + dx) ? (hx + dx) / 2 : 640; + E.spot += (mid - E.spot) * Math.min(1, E.dt * 3); + push(ctx); ctx.translate(E.ox + E.shx, E.oy + E.shy); ctx.scale(E.sc, E.sc); + ctx.globalAlpha = clamp((P.spot === 'warm' ? .5 : .62) * k * (E.A.night ? .55 : 1) * ga, 0, 1); ctx.drawImage(spotSpr(P.spot), E.spot - 300, E.gy - 62, 600, 150); + pop(ctx); + } + + // ---- Lapisan depan (layar penuh) ---- + function drawFront(ctx) { + const A = E.A, P = E.P, k = E.wk; + push(ctx); ctx.imageSmoothingEnabled = true; + crowd(ctx, A); + if (P.fog && k >= .75 && !E.light) fogBand(ctx, 2, k); + if (P.rain) rain(ctx, false, k); + if (P.bolt && !E.light && !E.calm) { // kilat lembut sesekali (tiap 19 dtk, dua kedipan singkat, redup) + const age = E.t + 4 - Math.floor((E.t + 4) / 19) * 19, f = age < .12 ? (1 - age / .12) * .16 : age > .2 && age < .3 ? (1 - (age - .2) / .1) * .1 : 0; + if (f > 0) { ctx.globalAlpha = f; ctx.fillStyle = 'rgb(205,220,255)'; ctx.fillRect(0, 0, E.vw, E.vh); ctx.globalAlpha = 1; } + } + pop(ctx); + } + // Siluet penonton di tepi bawah layar: satu path untuk semua badan per baris. Melompat dan mengangkat tangan menurut semangat C.ex, bertepuk saat kombo besar berakhir. + const MX = new Float32Array(64), MY = new Float32Array(64), MS = new Float32Array(64), MH = new Uint8Array(64), HX = new Float32Array(64), HY = new Float32Array(64); + function crowd(ctx, A) { + const cr = A.crowd || ARENA._.crowd, u = E.sc, light = E.light, calm = E.calm, t = calm ? 0 : E.t, ex = calm ? 0 : C.ex, clap = calm ? 0 : C.clap, holder = cr.acc === 'flag' || cr.acc === 'lantern'; + const n = clamp(Math.round(E.vw / (62 * u) * (light ? .7 : 1)), 6, 36), step = E.vw / n, ar = smooth(clamp(ex * 1.7, 0, 1)), cw = clap > 0 ? smooth(Math.min(1, clap * 2)) : 0; + for (let row = light ? 0 : 1; row >= 0; row--) { // baris belakang (1, lebih tinggi di layar) lebih dulu, lalu baris depan (0) + const back = row === 1, by = E.vh + (back ? -8 : 5) * u, sz0 = back ? .8 : 1; + for (let i = 0; i < n; i++) { + const id = i + row * 64, f = 2 + 1.5 * rnd(14, id, 5), ph = rnd(14, id, 6) * TAU; + const jump = ex > .02 ? ex * (8 + 10 * rnd(14, id, 7)) * u * Math.abs(Math.sin(t * f * Math.PI + ph)) : 0; + MX[i] = (i + .5 + (rnd(14, id, 3) - .5) * .7 + (back ? .5 : 0)) * step; MY[i] = by - jump + Math.sin(t * .8 + ph) * .8 * u; MS[i] = sz0 * (.9 + .24 * rnd(14, id, 4)) * u * 1.1; + MH[i] = rnd(14, id, 8) < (cr.acc === 'hat' ? .4 : .3) ? 1 : 0; // pembawa aksesori (topi kerucut, bendera, lentera) + } + ctx.globalAlpha = back ? .74 : .94; ctx.fillStyle = cr.tone; ctx.strokeStyle = cr.tone; + ctx.beginPath(); + for (let i = 0; i < n; i++) { const x = MX[i], y = MY[i], s = MS[i]; ctx.moveTo(x + 21 * s, y - 9 * s); ctx.ellipse(x, y - 9 * s, 21 * s, 15 * s, 0, 0, TAU); ctx.moveTo(x + 11 * s, y - 34 * s); ctx.arc(x, y - 34 * s, 11 * s, 0, TAU); } + ctx.fill(); + if (cr.acc === 'hat') { // topi kerucut + ctx.beginPath(); + for (let i = 0; i < n; i++) if (MH[i]) { const x = MX[i], y = MY[i], s = MS[i]; ctx.moveTo(x - 19 * s, y - 41 * s); ctx.lineTo(x, y - 62 * s); ctx.lineTo(x + 19 * s, y - 41 * s); ctx.closePath(); } + ctx.fill(); + } + // Lengan: terangkat saat bersemangat, merapat di depan dada saat bertepuk. Pembawa bendera/lentera menahan satu tangan (kanan) walau penonton tenang. + ctx.beginPath(); ctx.lineWidth = 7 * sz0 * u; ctx.lineCap = 'round'; + for (let i = 0; i < n; i++) { + const hold = holder && MH[i] === 1; + if (ar < .03 && !hold) continue; + const x = MX[i], y = MY[i], s = MS[i]; + for (let d = -1; d <= 1; d += 2) { + if (d < 0 && ar < .03) continue; + const hx = x + d * ((22 + 6 * Math.sin(t * 9 + i * 1.9 + d)) * (1 - cw) + (6 + 10 * (.5 + .5 * Math.sin(t * 15 + i))) * cw) * s, hy = y - (24 + 30 * ar * (1 - .55 * cw)) * s; + ctx.moveTo(x + d * 15 * s, y - 17 * s); ctx.lineTo(hx, hy); + if (d > 0) { HX[i] = hx; HY[i] = hy; } + } + } + ctx.stroke(); + if (!light && !back) { // cahaya tepi di atas kepala + ctx.globalAlpha = .45; ctx.strokeStyle = cr.rim; ctx.lineWidth = 1.6 * u; ctx.beginPath(); + for (let i = 0; i < n; i++) { const x = MX[i], y = MY[i], s = MS[i]; ctx.moveTo(x + 11 * s * Math.cos(3.5), y - 34 * s + 11 * s * Math.sin(3.5)); ctx.arc(x, y - 34 * s, 11 * s, 3.5, 5.9); } + ctx.stroke(); + } + if (!back && holder) accessories(ctx, cr, n, u, t); + } + ctx.globalAlpha = 1; + } + function accessories(ctx, cr, n, u, t) { // bendera kecil atau lentera di tangan kanan pembawa + for (let i = 0; i < n; i++) { + if (!MH[i]) continue; + const s = MS[i], hx = HX[i], hy = HY[i]; + if (cr.acc === 'flag') { + const sw = Math.sin(t * 5 + i) * 3 * s, tx = hx + sw * .3; + ctx.globalAlpha = .95; ctx.strokeStyle = cr.tone; ctx.lineWidth = 2 * u; ctx.beginPath(); ctx.moveTo(hx, hy + 4 * s); ctx.lineTo(tx, hy - 30 * s); ctx.stroke(); + ctx.fillStyle = cr.flags[i % cr.flags.length]; ctx.beginPath(); ctx.moveTo(tx, hy - 30 * s); ctx.lineTo(tx + 17 * s + sw, hy - 25 * s); ctx.lineTo(tx, hy - 19 * s); ctx.closePath(); ctx.fill(); + } else { + const g = 34 * s; ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = .9; ctx.drawImage(glow('warm'), hx - g / 2, hy - 8 * s - g / 2, g, g); ctx.globalCompositeOperation = 'source-over'; + } + } + } + + // ---- Menu: baris Cuaca di layar pilih arena ---- + let syncRow = null; + function decorate(root, screen) { + try { + if (screen !== 'arena' || !root || root.querySelector('.arena-row')) return; + const btn = root.querySelector('.start-battle'); if (!btn) return; + const row = document.createElement('div'), box = document.createElement('div'); + row.className = 'arena-row'; box.className = 'arena-weather'; box.setAttribute('role', 'group'); box.setAttribute('aria-label', 'Cuaca arena'); + box.innerHTML = 'CUACA