From 0b3492d3356552f449faea19b60119953d7ef48a Mon Sep 17 00:00:00 2001 From: rizkyaz0 Date: Mon, 5 Oct 2026 15:03:13 +0700 Subject: [PATCH] Latar arena hidup dan cuaca (RENCANA 6.1 dan 6.3): modul arena.js dengan awan, burung, kilau air, daun, kunang-kunang, penonton yang bereaksi, dan lima cuaca; baris Cuaca di layar arena dan Efek latar di Pengaturan - arena.js (window.Arena) + arena.css: semua digambar prosedural di kanvas, tanpa gambar baru. Lapisan belakang (antara latar dan petarung): awan yang bergeser (memudar saat melewati menara/atap lewat kotak keep per arena), burung walet, camar, kelelawar yang melintas sesekali, kilau air dan perahu layar kecil (Azure Harbor), sungai berkilau (Moonrise), debu cahaya dan berkas cahaya (Sunspire), daun dan kelopak jatuh (Elderwood), kunang-kunang, bintang berkedip dan bintang jatuh (malam). Lapisan depan: siluet penonton di tepi bawah (dua baris, topi kerucut, bendera, lentera) yang melompat dan mengangkat tangan saat kombo 5/8/12 dan K.O., serta bertepuk saat kombo besar berakhir. - Cuaca per pertandingan: Otomatis (deterministik dari arena: semua cerah, Elderwood kabut tipis, Moonrise malam), Cerah, Hujan (garis miring dua lapis, percikan di lantai, awan gelap, kilat lembut), Kabut (pita kabut bergeser), Senja (tint hangat, cahaya cakrawala dan matahari), Malam (tint biru, bintang, sorotan lantai yang mengikuti petarung). Hanya tampilan: tidak menyentuh mekanik, random() mesin, atau Date; semua gerak adalah fungsi waktu tanpa status (hash bilangan bulat), jadi advance() dan jeda aman. - Efek latar di Pengaturan: Penuh/Ringan/Mati (konoha.arena.v1; bawaan Ringan di HP). Gerak dikurangi (Access.motion atau prefers-reduced-motion) membekukan waktu latar dan mematikan burung, perahu, daun, debu, bintang jatuh, kilat, dan lompatan penonton. Modul mematikan diri bila gambar berulang kali galat. - Biaya dijaga setelah diukur pada rasterisasi perangkat lunak: vinyet dan kerudung warna depan jadi elemen DOM #arena-vig (nol biaya kanvas), kabut berupa ubin siap-ukuran digambar 1:1, gradasi langit lewat strip 1x256, blend besar memakai source-over; tanpa kerucut cahaya. - Kait: game.js draw() dua baris (Arena.back sesudah drawBackground, Arena.front di akhir); menu.js Arena.decorate di akhir render() dan weather: Arena.pick di startMatch (pembungkus __game.startMatch mencatatnya hanya bila pertandingan mulai; Arcade, Survival, tutorial memakai Otomatis); index.html dua tag di baris sendiri sebelum . Baris Cuaca disisipkan di sisi kiri tombol mulai, jadi tinggi layar arena tidak bertambah (diukur: 1280x720 dan 1024x520 sama seperti sebelumnya). - Mode ringan: node --check dan smoke lulus tanpa galat halaman; tampilan diuji manual oleh pemilik. precache.js dan files.json perlu dibangun ulang oleh integrator. --- arena.css | 19 ++ arena.js | 536 +++++++++++++++++++++++++++++++++++++++++++ docs/ENGINE_NOTES.md | 1 + game.js | 2 + index.html | 2 + menu.js | 3 +- 6 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 arena.css create mode 100644 arena.js 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
' + PICKS.map(id => '').join('') + '
'; + btn.before(row); row.append(box, btn); + const stage = root.querySelector('.arena-tile.selected')?.dataset.stage || 'bellora', note = box.querySelector('.aw-note'); + const sync = () => { + const off = S.fx === 'off'; + box.querySelectorAll('button[data-w]').forEach(b => { const on = b.dataset.w === S.pick; b.classList.toggle('selected', on); b.setAttribute('aria-pressed', String(on)); b.disabled = off; }); + note.textContent = off ? 'Efek latar dimatikan di Pengaturan' : S.pick === 'auto' ? 'Otomatis: ' + autoLabel(stage) : 'Tampilan saja, tanpa memengaruhi pertarungan'; + }; + box.addEventListener('click', ev => { const b = ev.target.closest('button[data-w]'); if (!b || b.disabled) return; S.pick = b.dataset.w; sync(); }); + syncRow = sync; sync(); + } catch (err) { console.error('[arena] menu:', err); } + } + // ---- Pengaturan: baris Efek latar ---- + function setFx(v) { + if (!FX.includes(v)) return false; + S.fx = v; save(); const sel = document.getElementById('arena-fx'); if (sel) sel.value = v; + try { syncRow?.(); } catch (_) { /* baris menu sudah dilepas */ } + return true; + } + function mountSettings() { + const panel = document.getElementById('settings-panel'); if (!panel || panel.querySelector('#arena-fx-row')) return; + const row = document.createElement('label'); row.className = 'setting-row'; row.id = 'arena-fx-row'; + row.innerHTML = 'Efek latarAwan, burung, cuaca, dan penonton di belakang petarung. Pilih Ringan atau Mati bila game terasa berat.'; + const sel = row.querySelector('select'); + for (const id of FX) { const o = document.createElement('option'); o.value = id; o.textContent = FXNAME[id] + (id === DEF_FX ? ' (bawaan)' : ''); sel.append(o); } + sel.value = S.fx; sel.addEventListener('change', () => setFx(sel.value)); + (panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(row); + } + // Cuaca per pertandingan: menu.js mengirim {weather} lewat startMatch; pembungkus ini mengingatnya hanya bila pertandingan benar-benar mulai. + // Arcade, Survival, tutorial, dan pemanggil lain tidak mengirim apa pun, jadi memakai Otomatis (deterministik dari arena). + function hookGame() { + const g = window.__game; if (!g || typeof g.startMatch !== 'function' || g.startMatch.__arena) return; + const sm = g.startMatch, wrapped = function (o) { const w = o && o.weather, ok = sm.apply(this, arguments); if (ok) S.active = PICKS.includes(w) ? w : 'auto'; return ok; }; + wrapped.__arena = true; g.startMatch = wrapped; + } + load(); + try { mountSettings(); } catch (err) { console.error('[arena] pengaturan:', err); } + const boot = () => { try { hookGame(); mountSettings(); } catch (err) { console.error('[arena] awal:', err); } }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); + + window.Arena = { + back, front, decorate, setFx, names: NAME, + get pick() { return S.pick; }, set pick(v) { if (PICKS.includes(v)) S.pick = v; syncRow?.(); }, + get fx() { return S.fx; }, + force(w) { S.forced = PICKS.includes(w) && w !== 'auto' ? w : null; return S.forced; }, + state() { return { on: E.on, dead: E.dead, fails: E.fails, stage: E.stage, weather: E.wx, strength: E.wk, fx: S.fx, pick: S.pick, active: S.active, forced: S.forced, calm: E.calm, night: E.night, crowd: { ...C } }; } + }; +})(); diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 2cd517b..b4d68cf 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -17,6 +17,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`. - Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2/3.3): data di `content/passives.js` (`window.PASSIVES[id] = {name, desc, flaw, mods}`, `window.STYLES` agresif/seimbang/bertahan), helper dan teks angka otomatis di `passives.js` (`window.Passives`: `merge`, `describe`, `lockerHtml`, `decorateVS`). Mesin: `refreshPassives(styles)` di `startMatch` (juga `selectCharacter`, `setP2`) menggabungkan perk + kelemahan + gaya per sisi ke `sideMods`; semua pembacaan lewat satu titik kait `passiveMod(actor, key, base)` (hero = sisi pemain; selain itu = lawan, CPU atau P2, jadi simetris). Kunci: `dmgOut`, `dmgBasic`, `dmgSkill`, `dmgIn`, `walk`, `cd`, `cdSkill`, `cdUlt`, `parry`, `modeDur` (pengali) dan `regen` (HP/dtk, penambah). Damage dikali di `hitDummy`/`receiveHit`/`dotDamage` lewat `passiveDamage` (dibulatkan dengan sisa yang dibawa, tanpa pengacak); cooldown di loop cooldown `step`/`updateDummy`; jalan di `step`/`updateP2`/`updateFenrAI`; parry di `press('l')` dan peluang blok CPU di `hitDummy`. Gaya pemain disimpan di `Meta.save.style[id]` (`Meta.styleOf/setStyle`, simpanan lama tanpa kunci = Seimbang); `startMatch({styles:{player,enemy}})` menimpa, dan CPU di pertandingan ber-VS memakai gaya acak (pengacak terpisah `styleRand`). Teks tampil di menu Karakter (`renderLocker`), layar VS (`.vs-perk`), dan Daftar gerakan (`passiveRows`). API uji: `__game.passives()`, `__game.passiveMod(actor,key,base)`, `__game.setStyles({player,enemy})`. Catatan: P2 manusia tidak punya tombol blok, jadi kunci `parry` tidak berefek bagi P2. - `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`. +- Latar hidup dan cuaca (RENCANA 6.1 dan 6.3): `arena.js` (`window.Arena`) + `arena.css`; tagnya di baris sendiri sebelum `` di `index.html` (urutan muat bebas: kait dipasang saat DOMContentLoaded). Kait: `game.js` `draw()` memanggil `Arena.back(ctx, {viewW, viewH, scale, offsetX, offsetY, shakeX, shakeY, groundY, stage, time: realTime, menu, combo: comboHits, cin: cinematic})` tepat sesudah `drawBackground` dan `Arena.front(ctx)` sebagai pernyataan terakhir; `menu.js` memanggil `Arena.decorate(root, screen)` di akhir `render()` dan mengirim `weather: Arena.pick` di `startMatch`. Pembungkus `__game.startMatch` (dipasang `arena.js`) mengingat `options.weather` hanya bila pertandingan benar-benar mulai; tanpa opsi (Arcade, Survival, tutorial, uji) cuaca = Otomatis, yaitu `ARENA[stage].auto` (semua cerah kecuali Elderwood kabut tipis dan Moonrise malam; arena yang belum ada di tabel memakai entri `_`). Cuaca (hanya tampilan, tidak menyentuh mekanik): Cerah, Hujan, Kabut, Senja, Malam; preset di `WX`. Semua gerak adalah fungsi waktu tanpa status (hash bilangan bulat `rnd(salt, i, k)`, bukan `random()` mesin dan bukan `Date`), jadi `advance()` yang melompat jauh, jeda (realTime berhenti), dan hasil pertandingan aman. Lapisan belakang (kanvas, antara latar dan petarung): tint pengali, awan (memudar di kotak `keep` bangunan karena digambar di atas gambar), burung/camar/kelelawar, kilau air dan perahu (Harbor, sungai Moonrise), debu cahaya dan berkas cahaya (Sunspire), daun dan kelopak (Elderwood), kunang-kunang, bintang, bintang jatuh (malam), hujan jauh dan percikan lantai, kabut, sorotan lantai. Lapisan depan (kanvas, layar penuh, tidak ikut zoom kamera): siluet penonton di tepi bawah (semangat `C.ex` naik saat kombo 5/8/12 dan K.O. dari `Feel.ko.state`, bertepuk saat kombo >= 5 berakhir), kabut dan hujan dekat, kilat lembut. Vinyet dan kerudung warna depan adalah elemen DOM `#arena-vig` tepat di atas kanvas (nol biaya gambar). Biaya dijaga: tanpa vinyet berskala, blend besar memakai source-over, kabut berupa ubin siap-ukuran digambar 1:1, gradasi langit lewat strip 1x256; dengan rasterisasi perangkat lunak tiap operasi layar penuh terukur ms, jadi tambah efek besar dengan hati-hati. Pengaturan: baris "Efek latar" Penuh/Ringan/Mati disisipkan `arena.js` ke `#settings-panel` (`localStorage['konoha.arena.v1']`, bawaan Ringan bila `html.touch`); Mati = tidak ada yang digambar dan baris Cuaca di layar arena dimatikan. Layar arena: baris Cuaca (Otomatis/Cerah/Hujan/Kabut/Senja/Malam) disisipkan di sisi kiri tombol mulai (ruang yang tadinya kosong), jadi tinggi layar tidak berubah di 1280x720 dan 1024x520. Gerak dikurangi (`Access.motion` atau prefers-reduced-motion): waktu latar dibekukan, burung/perahu/daun/debu/bintang jatuh/kilat dan lompatan penonton dimatikan. Arena baru: tambah satu entri di `ARENA` (horizon, cloud, keep, crowd, dst; lihat komentar di atas tabel). Konsol: `Arena.state()`, `Arena.force('hujan')` (null melepas), `Arena.setFx('light')`; URL `?cuaca=malam` memaksa cuaca di semua pertandingan. Kegagalan gambar mematikan modul sendiri (loop game tidak terganggu). Belum ada skenario otomatis (mode ringan, diuji manual); `precache.js`/`files.json` perlu dibangun ulang oleh integrator agar `arena.js` dan `arena.css` ikut tersimpan offline. - Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa. - `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`. - Gamepad dan ubah tombol (Tahap 4.5): `input.js` + `input.css` (`window.Input`), dimuat tepat setelah `touch.js`; satu-satunya kait ke game.js adalah `__game.input` (plus pembungkus `__game.startMatch/setP2` untuk mengetahui mode dua pemain; bila kelak ada `__game.p2Active()`, itu yang dipakai). **Ubah tombol** (Pengaturan > "Ubah tombol…", dialog `#keymap-dialog`): 11 aksi Pemain 1 disimpan di `localStorage['konoha.keymap.v1']` sebagai `{aksi: {c: e.code, l: label}}`. Hanya bila pemetaan berbeda dari bawaan, keydown/keyup ditangkap di fase capture pada `window`, diterjemahkan ke kunci kanonik (a d w s spasi i o p k l j) lewat `__game.input`, dan event aslinya dihentikan; tombol bawaan yang sudah dipindah ditelan (termasuk keyup-nya, supaya game.js tidak melepas kunci kanonik yang ditahan lewat tombol baru). Tidak aktif di menu depan (`body.in-menu`), dialog terbuka, layar VS/kartu cerita, atau kolom teks; Spasi/Enter pada `
Daftar gerakan
+ +
diff --git a/menu.js b/menu.js index da2260f..25f51b3 100644 --- a/menu.js +++ b/menu.js @@ -76,6 +76,7 @@ } bind();attachBackground();window.Brand?.decorate(root,screen);window.Mystery?.decorate(root,screen,hover,lockedSlots); window.Tutorial?.decorate(root,screen); // tombol TUTORIAL kecil di pojok kiri atas layar utama (tutorial.js) + window.Arena?.decorate?.(root,screen); // baris Cuaca di layar arena, di sisi kiri tombol mulai (arena.js) } function updatePreview(play=true){ root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover)); @@ -92,7 +93,7 @@ lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;if(runKind){const k=runKind;runKind=null;window.Meta?.startRun(k,player);document.querySelector('#arena').focus();return;}step=1;hover=ids.find(x=>x!==player)||player;}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000); } function back(){if(lockIn)return;game.stopAnnouncer();sfx('back',true);if(screen==='arena'){screen='select';step=1;hover=enemy;}else if(screen==='select'&&step===1){step=0;hover=player;}else screen='home';render();} - function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode,vs:mode==='versus'?{p2}:false})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}} + function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode,weather:window.Arena?.pick,vs:mode==='versus'?{p2}:false})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}} // Opens the shop/locker/collection. If meta.js is missing (stale cached index.html) it is loaded on demand. function openMeta(tab){ const go=()=>{try{window.Meta.open(tab);}catch(e){console.error(e);alert('Menu gagal dibuka: '+e.message);}};